From 15e5d7bbca668085c76280eb6fcf4df5603985d3 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 8 May 2026 20:08:21 +0200 Subject: [PATCH] =?UTF-8?q?Type-aware=20metadata=20filter=20=E2=80=94=20fl?= =?UTF-8?q?ags=20/=20numeric=20comparisons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predicate now branches on column property type before falling back to substring: Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/Metadata/MetadataFilterTests.cs | 102 ++++++++++++++ ILSpy/ViewModels/MetadataTablePageModel.cs | 143 ++++++++++++++++++-- 2 files changed, 232 insertions(+), 13 deletions(-) diff --git a/ILSpy.Tests/Metadata/MetadataFilterTests.cs b/ILSpy.Tests/Metadata/MetadataFilterTests.cs index 7c3ce4a5f..94445b887 100644 --- a/ILSpy.Tests/Metadata/MetadataFilterTests.cs +++ b/ILSpy.Tests/Metadata/MetadataFilterTests.cs @@ -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 "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() { diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index fb3bdbc95..e22b141d4 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -29,6 +29,8 @@ using Avalonia.Controls; using CommunityToolkit.Mvvm.ComponentModel; +using ILSpy.Metadata; + namespace ILSpy.ViewModels { /// @@ -108,9 +110,18 @@ namespace ILSpy.ViewModels /// /// Returns true when every in /// either has an empty value or the matching property on - /// stringifies to a value that satisfies the filter. Plain text matches as a - /// case-insensitive substring; a filter wrapped in /.../ is parsed as a - /// case-insensitive regex (silently downgrading to substring on parse failure). + /// satisfies the filter. The predicate is type-aware: + /// + /// regex when the filter is wrapped in /.../ + /// enums: comma-separated flag names with + /// ! prefix for negation; all named flags must match (AND) + /// integer columns: optional comparison operator (>, <, + /// >=, <=, =) followed by a hex (0x…) or + /// decimal literal + /// otherwise: case-insensitive substring on the stringified value + /// + /// Type-aware modes silently fall through to the substring path on a parse failure + /// so a typo can't take down the filter. /// public static bool MatchesFilters(object item, IEnumerable filters) { @@ -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(); + 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,