Browse Source

Type-aware metadata filter — flags / numeric comparisons

The predicate now branches on column property type before falling back
to substring:

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
15e5d7bbca
  1. 102
      ILSpy.Tests/Metadata/MetadataFilterTests.cs
  2. 143
      ILSpy/ViewModels/MetadataTablePageModel.cs

102
ILSpy.Tests/Metadata/MetadataFilterTests.cs

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Linq;
using System.Threading.Tasks;
@ -160,6 +161,107 @@ public class MetadataFilterTests @@ -160,6 +161,107 @@ public class MetadataFilterTests
"plain-text '(Runtime' (no slashes) matches as a literal substring");
}
[Flags]
enum SampleFlags { None = 0, Public = 1, Static = 2, Sealed = 4, Private = 8 }
sealed class FlagsEntry
{
public SampleFlags Attributes { get; set; }
}
[Test]
public void Filter_Matches_Single_Flag_Name_When_Set_On_Flags_Enum_Column()
{
var entry = new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Attributes") { Text = "Public" },
}).Should().BeTrue();
}
[Test]
public void Filter_Matches_When_All_Comma_Separated_Flag_Names_Are_Set_Regardless_Of_Order()
{
// Substring fallback would fail "Static, Public" against "Public, Static" since the
// names are out of order — the [Flags]-aware predicate matches anyway.
var entry = new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Attributes") { Text = "Static, Public" },
}).Should().BeTrue();
}
[Test]
public void Filter_Misses_When_Any_Named_Flag_Is_Not_Set()
{
var entry = new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Attributes") { Text = "Public, Sealed" },
}).Should().BeFalse();
}
[Test]
public void Filter_With_Bang_Prefix_Requires_Flag_To_Be_Cleared()
{
// `!Name` reads as "this flag must NOT be set" — useful for narrowing to
// non-static methods, non-public members, etc.
var entry = new FlagsEntry { Attributes = SampleFlags.Public };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Attributes") { Text = "!Static" },
}).Should().BeTrue();
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Attributes") { Text = "Public, !Static" },
}).Should().BeTrue();
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Attributes") { Text = "!Public" },
}).Should().BeFalse();
}
[Test]
public void Filter_Falls_Back_To_Substring_When_Token_Is_Not_A_Valid_Flag_Name()
{
// "Pub" alone isn't a flag name, so the predicate drops to substring on the
// formatted enum value ("Public, Static") — which contains "Pub".
var entry = new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Attributes") { Text = "Pub" },
}).Should().BeTrue();
}
sealed class NumericEntry
{
[ColumnInfo("X8")]
public int Token { get; set; }
}
[Test]
public void Filter_With_Comparison_Operator_Matches_Numerically_On_Integer_Column()
{
var entry = new NumericEntry { Token = 0x06000010 };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Token") { Text = ">0x06000000" },
}).Should().BeTrue();
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Token") { Text = "<0x06000000" },
}).Should().BeFalse();
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Token") { Text = "=0x06000010" },
}).Should().BeTrue();
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Token") { Text = ">=0x06000010" },
}).Should().BeTrue();
}
[Test]
public void Filter_With_Plain_Hex_Or_Decimal_Falls_Back_To_Substring_On_Integer_Columns()
{
// Without an operator the user is just searching — substring on the formatted
// value (which is what the column displays) is the most predictable behavior.
// "06000010" matches the formatted "06000010" of Token=0x06000010.
var entry = new NumericEntry { Token = 0x06000010 };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Token") { Text = "06000010" },
}).Should().BeTrue();
}
[Test]
public void MatchesFilters_Returns_False_When_The_Filtered_Column_Does_Not_Exist_On_The_Row()
{

143
ILSpy/ViewModels/MetadataTablePageModel.cs

@ -29,6 +29,8 @@ using Avalonia.Controls; @@ -29,6 +29,8 @@ using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using ILSpy.Metadata;
namespace ILSpy.ViewModels
{
/// <summary>
@ -108,9 +110,18 @@ namespace ILSpy.ViewModels @@ -108,9 +110,18 @@ namespace ILSpy.ViewModels
/// <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"/>
/// stringifies to a value that satisfies the filter. Plain text matches as a
/// case-insensitive substring; a filter wrapped in <c>/.../</c> is parsed as a
/// case-insensitive regex (silently downgrading to substring on parse failure).
/// 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)
{
@ -125,24 +136,130 @@ namespace ILSpy.ViewModels @@ -125,24 +136,130 @@ namespace ILSpy.ViewModels
BindingFlags.Public | BindingFlags.Instance));
if (prop is null)
return false;
object? value;
try
{ value = prop.GetValue(item); }
catch { return false; }
var s = value?.ToString();
if (s is null)
if (!MatchesOne(item, prop, f.Text))
return false;
if (TryGetRegex(f.Text) is { } rx)
}
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('!'))
{
if (!rx.IsMatch(s))
return false;
negate = true;
token = token[1..].Trim();
}
else if (!s.Contains(f.Text, StringComparison.OrdinalIgnoreCase))
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,

Loading…
Cancel
Save