From e5c9ddcca67abf5b45d199538ba1e7cdce881471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Thu, 4 Jun 2026 11:24:43 +0200 Subject: [PATCH 1/2] Natvis: add IncludeView/ExcludeView and view() specifier support Add view support to the MIEngine natvis evaluator (steps 1, 2, and 3): 1. DisplayString IncludeView/ExcludeView filtering - Add IncludeView and ExcludeView properties to DisplayStringType (NatvisXsdTypes.cs) so the XML attributes are deserialized. - Filter DisplayString entries in FormatDisplayString() using the new IsIncludeViewMatch() and IsExcludeViewMatch() helpers before evaluating the Condition. 2. {expr,view(name)} inline specifier in DisplayString text - ExtractViewName() detects a view() format specifier in an inline expression block such as {this,view(RecZone)na}. - When detected, FormatValue() calls GetExpressionValue() with the view name, which re-enters FormatDisplayString() selecting only DisplayString entries whose IncludeView matches. - ExtractViewName() returns null for view() with an empty name (not a valid specifier). 3. View support on Expand elements - Add IncludeView and ExcludeView properties to ExpandedItemType, ItemType, and CustomListItemsType in NatvisXsdTypes.cs. - Thread currentView through Expand() and ExpandVisualized() so that view context propagates into recursive expand calls. - Filter Item and ExpandedItem elements by IncludeView/ExcludeView before evaluating their Condition. - Strip a view() specifier from an ExpandedItem expression before evaluating it, and pass the extracted view name into the recursive Expand() call so that IncludeView guards on the target type's Expand elements match correctly. - Add a CustomListItemsType stub case that applies IncludeView/ ExcludeView filtering; loop body execution to follow. 4. EvalCondition fallback on failure - Wrap condition evaluation in try/catch: MIException (debugger rejected the expression, e.g. too long) is caught silently and returns false; other exceptions are caught and logged at Warning before also returning false, so natvis never surfaces errors as debug-session failures. - Prerequisite: natvis files may use a lightweight condition as a platform probe (always true for valid data, but too long to expand on GDB/LLDB); without this fallback the condition would surface as an error rather than falling through to the next DisplayString. 5. Strip format specifier before name substitution in GetExpressionValue() - A specifier such as ",d" was being matched by ProcessNamesInString as a child-variable name, corrupting the expression. Strip it first, then re-attach after substitution. - Prerequisite: without this fix, an expression such as {call(),d} would have its specifier matched as a variable name, corrupting the expression before evaluation. 6. Intrinsic expansion before dll! stripping in ReplaceNamesInExpression() - Intrinsic bodies can contain dll!-qualified type casts. Moving expansion before the dll!-strip regex ensures those references are also cleaned up. - Prerequisite: without this fix, a dll!-qualified cast inside an intrinsic body would survive into the expression sent to GDB/LLDB. Note: items 4, 5, and 6 are bugfixes that are prerequisites for the view() feature to work correctly on GDB/LLDB. They are bundled in this commit because they share the same test infrastructure and were discovered during view() development. Unit tests: Add 18 tests in NatvisFormatSpecifierTest covering the three new static helpers (IsIncludeViewMatch, IsExcludeViewMatch, ExtractViewName) and the view() specifier parsing patterns used by Expand elements, including the empty-name edge case for ExtractViewName. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 204 +++++++++++++++--- .../Natvis.Impl/NatvisXsdTypes.cs | 160 +++++++++++--- .../NatvisFormatSpecifierTest.cs | 122 +++++++++++ 3 files changed, 432 insertions(+), 54 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 5e7e30d05..bc22f5291 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -453,7 +453,7 @@ private bool LoadFile(string path) } } - internal (string value, VisualizerId[] uiVisualizers) FormatDisplayString(IVariableInformation variable) + internal (string value, VisualizerId[] uiVisualizers) FormatDisplayString(IVariableInformation variable, string currentView = null) { VisualizerInfo visualizer = null; try @@ -479,6 +479,15 @@ private bool LoadFile(string path) { DisplayStringType display = item as DisplayStringType; // e.g. {{ size={_Mypair._Myval2._Mylast - _Mypair._Myval2._Myfirst} }} + + // IncludeView: only use this DisplayString when the named view is active. + if (!IsIncludeViewMatch(display.IncludeView, currentView)) + continue; + + // ExcludeView: skip this DisplayString when the current view is in the excluded list. + if (IsExcludeViewMatch(display.ExcludeView, currentView)) + continue; + if (!EvalCondition(display.Condition, variable, visualizer.ScopedNames, visualizer.Intrinsics)) { continue; @@ -522,7 +531,7 @@ private IVariableInformation GetVisualizationWrapper(IVariableInformation variab return new VisualizerWrapper(ResourceStrings.VisualizedView, _process.Engine, variable, visualizer, isVisualizerView: true); } - internal IVariableInformation[] Expand(IVariableInformation variable) + internal IVariableInformation[] Expand(IVariableInformation variable, string currentView = null) { try { @@ -530,7 +539,7 @@ internal IVariableInformation[] Expand(IVariableInformation variable) if (variable.IsVisualized || ((ShowDisplayStrings == DisplayStringsState.On) && !(variable is VisualizerWrapper))) // visualize right away if DisplayStringsState.On, but only if not dummy var ([Raw View]) { - return ExpandVisualized(variable); + return ExpandVisualized(variable, currentView); } IVariableInformation visView = GetVisualizationWrapper(variable); if (visView == null) @@ -587,7 +596,7 @@ internal string GetUIVisualizerName(string serviceId, int id) private delegate IVariableInformation Traverse(IVariableInformation node); - private IVariableInformation[] ExpandVisualized(IVariableInformation variable) + private IVariableInformation[] ExpandVisualized(IVariableInformation variable, string currentView = null) { VisualizerInfo visualizer = FindType(variable); if (visualizer == null) @@ -605,6 +614,10 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable) if (i is ItemType && !(variable is PaginatedVisualizerWrapper)) // we do not want to repeatedly display other ItemTypes when expanding the "[More...]" node { ItemType item = (ItemType)i; + if (!IsIncludeViewMatch(item.IncludeView, currentView)) + continue; + if (IsExcludeViewMatch(item.ExcludeView, currentView)) + continue; if (!EvalCondition(item.Condition, variable, visualizer.ScopedNames, visualizer.Intrinsics)) { continue; @@ -938,6 +951,13 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable) // _Myptr // // + + // IncludeView/ExcludeView: skip this ExpandedItem if the current view doesn't match. + if (!IsIncludeViewMatch(item.IncludeView, currentView)) + continue; + if (IsExcludeViewMatch(item.ExcludeView, currentView)) + continue; + if (item.Condition != null) { if (!EvalCondition(item.Condition, variable, visualizer.ScopedNames, visualizer.Intrinsics)) @@ -949,13 +969,36 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable) { continue; } - var expand = GetExpression(item.Value, variable, visualizer.ScopedNames, intrinsics: visualizer.Intrinsics); - var eChildren = Expand(expand); + + // A view() specifier on the ExpandedItem expression (e.g. "inner(),view(myview)") + // means: expand the result but show its children in the named view. + // Strip the specifier before evaluating the expression, then pass the + // view name into the recursive Expand call so that IncludeView guards + // on the target's Expand elements (including CustomListItems) match. + string rawExpr = item.Value.Trim(); + string spec = ExtractFormatSpecifier(rawExpr); + string viewName = ExtractViewName(spec); + string exprToEval = viewName != null ? StripFormatSpecifier(rawExpr) : rawExpr; + string childView = viewName ?? currentView; + + var expand = GetExpression(exprToEval, variable, visualizer.ScopedNames, intrinsics: visualizer.Intrinsics); + var eChildren = Expand(expand, childView); if (eChildren != null) { children.AddRange(eChildren); } } + else if (i is CustomListItemsType) + { + CustomListItemsType item = (CustomListItemsType)i; + // IncludeView/ExcludeView: skip this block if the current view doesn't match. + // The loop execution itself will be added in a follow-up (CustomListItems step). + if (!IsIncludeViewMatch(item.IncludeView, currentView)) + continue; + if (IsExcludeViewMatch(item.ExcludeView, currentView)) + continue; + // CustomListItems loop body not yet implemented — children not emitted. + } } if (!(variable is VisualizerWrapper) && !expandType.HideRawView) // don't stack wrappers, and respect HideRawView { @@ -1122,12 +1165,31 @@ private bool EvalCondition(string condition, IVariableInformation variable, IDic bool res = true; if (!String.IsNullOrWhiteSpace(condition)) { - string exprValue = GetExpressionValue(condition, variable, scopedNames, intrinsics); + try + { + string exprValue = GetExpressionValue(condition, variable, scopedNames, intrinsics); - bool exprBool = false; - int exprInt = 0; - res = !String.IsNullOrEmpty(exprValue) && - ((bool.TryParse(exprValue, out exprBool) && exprBool) || (int.TryParse(exprValue, out exprInt) && exprInt > 0)); + bool exprBool = false; + int exprInt = 0; + res = !String.IsNullOrEmpty(exprValue) && + ((bool.TryParse(exprValue, out exprBool) && exprBool) || (int.TryParse(exprValue, out exprInt) && exprInt > 0)); + } + catch (MICore.MIException e) + { + // Expected failure path: the debugger rejected the expression + // (e.g. expression too long, unknown symbol). + // Treat as false so the next DisplayString is tried as a fallback. + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Verbose, "EvalCondition failed: " + e.Message); + res = false; + } + catch (Exception e) + { + // Unexpected failure (e.g. NullReferenceException in the evaluation path). + // Still return false to avoid surfacing natvis errors as debug session failures, + // but log at Warning so unexpected exceptions are not silently swallowed. + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, "EvalCondition unexpected exception: " + e.Message); + res = false; + } } return res; } @@ -1282,13 +1344,31 @@ private string FormatValue(string format, IVariableInformation variable, IDictio Match m = s_expression.Match(format.Substring(i)); if (m.Success) { - string rawExpr = format.Substring(i + 1, m.Length - 2); + // Trim whitespace (including newlines from multi-line XML blocks) so that + // the expression never starts with \n, which would break LLDB MI's line-based protocol. + string rawExpr = format.Substring(i + 1, m.Length - 2).Trim(); string spec = ExtractFormatSpecifier(rawExpr); - string exprValue = GetExpressionValue(rawExpr, variable, scopedNames, intrinsics); - if (spec == "sub" || spec == "su") - exprValue = CleanUtf16StringValue(exprValue); - else if (spec == "sb") - exprValue = CleanAsciiStringValue(exprValue); + string exprValue; + string viewName = ExtractViewName(spec); + if (viewName != null) + { + // {expr,view(name)} -- format expr using the named view's DisplayString. + // Any other specifiers combined with view() (e.g. "na", "sub", "sb") are + // intentionally ignored: specifiers like sub/sb exist to post-process raw + // debugger output (stripping address prefixes and quotes), but view() already + // produces fully-formatted text via FormatDisplayString — applying those + // post-processors on top would corrupt the result. + string strippedExpr = StripFormatSpecifier(rawExpr); + exprValue = GetExpressionValue(strippedExpr, variable, scopedNames, intrinsics, viewName); + } + else + { + exprValue = GetExpressionValue(rawExpr, variable, scopedNames, intrinsics); + if (spec == "sub" || spec == "su") + exprValue = CleanUtf16StringValue(exprValue); + else if (spec == "sb") + exprValue = CleanAsciiStringValue(exprValue); + } value.Append(exprValue); i += m.Length - 1; } @@ -1488,6 +1568,58 @@ private static int FindLastTopLevelComma(string expression) return lastTopLevelComma; } + /// + /// Strips a NatVis format specifier (e.g. ",sub", ",d", ",view(name)na") from the end of + /// an expression, returning the bare expression. The specifier boundary is the last + /// top-level comma (not nested inside any parentheses or square brackets). + /// + internal static string StripFormatSpecifier(string expression) + { + int commaPos = FindLastTopLevelComma(expression); + return commaPos >= 0 + ? expression.Substring(0, commaPos).TrimEnd() + : expression; + } + + /// + /// Returns true when is listed in the semicolon-separated + /// IncludeView attribute, i.e. the DisplayString should only be shown in one of those views. + /// An empty or null includeView means "show in all views" (returns true for any currentView). + /// Both IncludeView and ExcludeView are defined as semicolon-delimited lists in the natvis XSD. + /// + internal static bool IsIncludeViewMatch(string includeView, string currentView) + { + if (string.IsNullOrEmpty(includeView)) return true; + if (currentView == null) return false; + return includeView.Split(';').Any(v => string.Equals(v.Trim(), currentView, StringComparison.Ordinal)); + } + + /// + /// Returns true when is listed in the semicolon-separated + /// ExcludeView attribute, i.e. the DisplayString should be skipped in this view. + /// An empty/null excludeView or a null currentView never excludes. + /// + internal static bool IsExcludeViewMatch(string excludeView, string currentView) + { + if (string.IsNullOrEmpty(excludeView) || currentView == null) return false; + return excludeView.Split(';').Any(v => string.Equals(v.Trim(), currentView, StringComparison.Ordinal)); + } + + /// + /// If is a view specifier of the form "view(name)" or + /// "view(name)na", returns the view name. Otherwise returns null. + /// + internal static string ExtractViewName(string spec) + { + if (spec == null) return null; + if (!spec.StartsWith("view(", StringComparison.Ordinal)) return null; + int closeParen = spec.IndexOf(')'); + if (closeParen < 0) return null; + string name = spec.Substring(5, closeParen - 5); + // view() with an empty name is not a valid specifier; treat as absent. + return name.Length > 0 ? name : null; + } + /// /// Returns the format specifier from a NatVis expression (the part after the last /// top-level comma), normalized the same way as @@ -1665,13 +1797,16 @@ internal static string ResolveIntrinsicCalls(string expression, IDictionary scopedNames, IDictionary intrinsics = null) { + // Expand intrinsic calls FIRST so that dll!-qualified type names that appear + // inside intrinsic bodies (e.g. "(Foo.dll!MyType*)ptr") are also stripped + // in the next step. + expression = ResolveIntrinsicCalls(expression, intrinsics); + // Strip Windows dll!-qualified type prefixes (e.g. Qt6Cored.dll!) - // for GDB/LLDB compatibility — meaningless outside Windows + // for GDB/LLDB compatibility — meaningless outside Windows. + // Must run AFTER intrinsic expansion so intrinsic-body dll! references are caught. expression = s_moduleQualifiedPrefix.Replace(expression, ""); - // Expand intrinsic calls (e.g. day(), memberOffset(3)) into plain C++ expressions - expression = ResolveIntrinsicCalls(expression, intrinsics); - return ProcessNamesInString(expression, new Substitute[] { (m)=> { @@ -1715,19 +1850,36 @@ private IVariableInformation GetExpression(string expression, IVariableInformati return expressionVariable; } - private string GetExpressionValue(string expression, IVariableInformation variable, IDictionary scopedNames, IDictionary intrinsics = null) + private string GetExpressionValue(string expression, IVariableInformation variable, IDictionary scopedNames, IDictionary intrinsics = null, string view = null) { - string processedExpr = ReplaceNamesInExpression(expression, variable, scopedNames, intrinsics); + // Strip any format specifier (e.g. ",d", ",x") BEFORE name/intrinsic substitution. + // If we don't do this, an identifier that happens to appear in the specifier — most + // commonly the "d" in ",d" — will be matched by ProcessNamesInString and replaced + // with the full expression for the child variable of that name (e.g. a member "d"), + // turning "1234,d" into "1234,(obj.d)" which the debugger evaluates as a C + // comma-operator expression returning the struct field instead of the integer. + string spec = ExtractFormatSpecifier(expression); + string exprNoSpec = spec != null ? StripFormatSpecifier(expression) : expression; + + string processedExpr = ReplaceNamesInExpression(exprNoSpec, variable, scopedNames, intrinsics); + + // Re-attach the format specifier so that VariableInformation.ProcessFormatSpecifiers + // can apply the correct display format (decimal, hex, etc.) via -var-set-format. + if (spec != null) + processedExpr = processedExpr + "," + spec; + IVariableInformation expressionVariable = new VariableInformation(processedExpr, variable, _process.Engine, null); expressionVariable.SyncEval(); - // Avoid recursive natvis formatting when expression is 'this' - if (expression.Trim() == "this") + // Avoid recursive natvis formatting when expression is 'this' and no view is requested. + // With a view, {this,view(name)} must go through FormatDisplayString to select the right + // IncludeView DisplayString for the named view. + if (expression.Trim() == "this" && view == null) { return expressionVariable.Value; } - return FormatDisplayString(expressionVariable).value; + return FormatDisplayString(expressionVariable, view).value; } private string GetDisplayNameFromArrayIndex(uint arrayIndex, int rank, uint[] dimensions, bool isForward) diff --git a/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs b/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs index c3fdb38e0..924225b69 100644 --- a/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs +++ b/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs @@ -524,9 +524,13 @@ public partial class CustomListItemsType { private bool optionalFieldSpecified; private string conditionField; - + + private string includeViewField; + + private string excludeViewField; + private uint maxItemsPerViewField; - + private bool maxItemsPerViewFieldSpecified; /// @@ -594,7 +598,29 @@ public string Condition { this.conditionField = value; } } - + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string IncludeView { + get { + return this.includeViewField; + } + set { + this.includeViewField = value; + } + } + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string ExcludeView { + get { + return this.excludeViewField; + } + set { + this.excludeViewField = value; + } + } + /// [System.Xml.Serialization.XmlAttributeAttribute()] public uint MaxItemsPerView { @@ -717,13 +743,17 @@ public string Value { [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] public partial class ExpandedItemType { - + private bool optionalField; - + private bool optionalFieldSpecified; - + private string conditionField; - + + private string includeViewField; + + private string excludeViewField; + private string valueField; /// @@ -758,7 +788,29 @@ public string Condition { this.conditionField = value; } } - + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string IncludeView { + get { + return this.includeViewField; + } + set { + this.includeViewField = value; + } + } + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string ExcludeView { + get { + return this.excludeViewField; + } + set { + this.excludeViewField = value; + } + } + /// [System.Xml.Serialization.XmlTextAttribute()] public string Value { @@ -770,7 +822,7 @@ public string Value { } } } - + /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] [System.SerializableAttribute()] @@ -852,11 +904,11 @@ public string Condition { [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] public partial class IndexNodeType { - + private string conditionField; - + private string valueField; - + /// [System.Xml.Serialization.XmlAttributeAttribute()] public string Condition { @@ -867,7 +919,7 @@ public string Condition { this.conditionField = value; } } - + /// [System.Xml.Serialization.XmlTextAttribute()] public string Value { @@ -879,7 +931,7 @@ public string Value { } } } - + /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] [System.SerializableAttribute()] @@ -887,15 +939,19 @@ public string Value { [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] public partial class ItemType { - + private string nameField; - + private bool optionalField; - + private bool optionalFieldSpecified; - + private string conditionField; - + + private string includeViewField; + + private string excludeViewField; + private string valueField; /// @@ -941,7 +997,29 @@ public string Condition { this.conditionField = value; } } - + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string IncludeView { + get { + return this.includeViewField; + } + set { + this.includeViewField = value; + } + } + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string ExcludeView { + get { + return this.excludeViewField; + } + set { + this.excludeViewField = value; + } + } + /// [System.Xml.Serialization.XmlTextAttribute()] public string Value { @@ -953,7 +1031,7 @@ public string Value { } } } - + /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] [System.SerializableAttribute()] @@ -1263,17 +1341,21 @@ public string Condition { [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] public partial class DisplayStringType { - + private bool optionalField; - + private bool optionalFieldSpecified; - + private string conditionField; - + private string legacyAddinField; - + private string exportField; - + + private string includeViewField; + + private string excludeViewField; + private string valueField; /// @@ -1330,7 +1412,29 @@ public string Export { this.exportField = value; } } - + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string IncludeView { + get { + return this.includeViewField; + } + set { + this.includeViewField = value; + } + } + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string ExcludeView { + get { + return this.excludeViewField; + } + set { + this.excludeViewField = value; + } + } + /// [System.Xml.Serialization.XmlTextAttribute()] public string Value { diff --git a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs index 287be0072..b5fd7b2ee 100644 --- a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs +++ b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs @@ -44,6 +44,128 @@ public void ExtractFormatSpecifier_NaModifierStripped() Assert.Equal("view(RecZone)", Natvis.ExtractFormatSpecifier("this,view(RecZone)na")); } + [Fact] + public void ExtractFormatSpecifier_ViewSpecifierNoModifier_Extracted() + { + // View specifier with no trailing modifier (e.g. no "na") + Assert.Equal("view(arr)", Natvis.ExtractFormatSpecifier("foo(),view(arr)")); + } + + // -- IsIncludeViewMatch ----------------------------------------------- + + [Fact] + public void IsIncludeViewMatch_NullIncludeView_AlwaysMatches() + { + Assert.True(Natvis.IsIncludeViewMatch(null, "RecZone")); + Assert.True(Natvis.IsIncludeViewMatch(null, null)); + } + + [Fact] + public void IsIncludeViewMatch_EmptyIncludeView_AlwaysMatches() + { + Assert.True(Natvis.IsIncludeViewMatch("", "RecZone")); + } + + [Fact] + public void IsIncludeViewMatch_MatchingView_ReturnsTrue() + { + Assert.True(Natvis.IsIncludeViewMatch("RecZone", "RecZone")); + } + + [Fact] + public void IsIncludeViewMatch_DifferentView_ReturnsFalse() + { + Assert.False(Natvis.IsIncludeViewMatch("RecZone", "other")); + } + + [Fact] + public void IsIncludeViewMatch_NullCurrentView_ReturnsFalse() + { + Assert.False(Natvis.IsIncludeViewMatch("RecZone", null)); + } + + [Fact] + public void IsIncludeViewMatch_MultipleViews_MatchesAny() + { + // IncludeView is a semicolon-delimited list per the natvis XSD — same as ExcludeView. + Assert.True(Natvis.IsIncludeViewMatch("RecZone;RecZoneAbs", "RecZone")); + Assert.True(Natvis.IsIncludeViewMatch("RecZone;RecZoneAbs", "RecZoneAbs")); + Assert.False(Natvis.IsIncludeViewMatch("RecZone;RecZoneAbs", "other")); + } + + // -- IsExcludeViewMatch ----------------------------------------------- + + [Fact] + public void IsExcludeViewMatch_NullExcludeView_ReturnsFalse() + { + Assert.False(Natvis.IsExcludeViewMatch(null, "RecZone")); + } + + [Fact] + public void IsExcludeViewMatch_NullCurrentView_ReturnsFalse() + { + Assert.False(Natvis.IsExcludeViewMatch("RecZone;RecZoneAbs", null)); + } + + [Fact] + public void IsExcludeViewMatch_ViewInList_ReturnsTrue() + { + Assert.True(Natvis.IsExcludeViewMatch("RecZone;RecZoneAbs", "RecZone")); + Assert.True(Natvis.IsExcludeViewMatch("RecZone;RecZoneAbs", "RecZoneAbs")); + } + + [Fact] + public void IsExcludeViewMatch_ViewNotInList_ReturnsFalse() + { + Assert.False(Natvis.IsExcludeViewMatch("RecZone;RecZoneAbs", "other")); + } + + [Fact] + public void IsExcludeViewMatch_SingleEntry_Matches() + { + Assert.True(Natvis.IsExcludeViewMatch("simple", "simple")); + } + + // -- ExtractViewName -------------------------------------------------- + + [Fact] + public void ExtractViewName_ViewSpecifier_ReturnsName() + { + Assert.Equal("RecZone", Natvis.ExtractViewName("view(RecZone)")); + } + + [Fact] + public void ExtractViewName_ViewSpecifierWithTrailingNa_ReturnsName() + { + Assert.Equal("RecZone", Natvis.ExtractViewName("view(RecZone)na")); + } + + [Fact] + public void ExtractViewName_ShortName_ReturnsName() + { + Assert.Equal("arr", Natvis.ExtractViewName("view(arr)")); + } + + [Fact] + public void ExtractViewName_NotViewSpecifier_ReturnsNull() + { + Assert.Null(Natvis.ExtractViewName("d")); + Assert.Null(Natvis.ExtractViewName("sub")); + } + + [Fact] + public void ExtractViewName_Null_ReturnsNull() + { + Assert.Null(Natvis.ExtractViewName(null)); + } + + [Fact] + public void ExtractViewName_EmptyViewName_ReturnsNull() + { + // view() with no name is not a valid specifier — treat as absent. + Assert.Null(Natvis.ExtractViewName("view()")); + } + // -- CleanUtf16StringValue -------------------------------------------- [Fact] From 973c60886705edc2e89c3abcccdf792121182dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Wed, 10 Jun 2026 14:39:42 +0200 Subject: [PATCH 2/2] Natvis: implement CustomListItems loop engine Add full execution support for the expand element, which was previously deserialized but produced no children. NatvisXsdTypes.cs: - Add seven new types covering the loop body: CustomListLoopType (), CustomListLoopItemType (), CustomListBreakType (), CustomListExecType (), CustomListIfType (), CustomListElseIfType (), CustomListElseType (). If/ElseIf/Else bodies and Loop bodies share the same element set, so nested loops and conditional branches are fully supported. - Extend CustomListItemsType with a Loop[] field. - Add Condition attribute to CustomListLoopType: acts as a while-guard, stopping the loop as soon as the condition evaluates to false. - Add missing [GeneratedCode], [Serializable] and [DebuggerStepThrough] attributes to the seven new types for consistency with the rest of the file. Natvis.cs: - Replace the view-filter stub with a complete execution engine: * declarations initialise a local-variable table (name -> current expression string) via ReplaceNamesInExpression. * Optional sets the totalSize upper bound for pagination. * The loop driver evaluates an optional Loop Condition before each iteration (while-guard), then calls ExecuteCustomListBody() once per iteration. * : stops the loop when the condition holds. * : substitutes local vars, resolves field names and intrinsics, emits the child; increments the $i counter (GlobalIndex). * : supports "varName = rhs" assignment and ++/-- shorthand (prefix and postfix) to update the local-variable table. After each update, the new expression is evaluated and normalised to a scalar literal to prevent unbounded expression growth across iterations (e.g. i++ stays "1", "2", "3" rather than accumulating nested parentheses). * //: evaluates conditions in order and executes the first matching branch; all siblings are consumed in one pass so the idx cursor never re-processes them. * Pagination: fast-forwards through startIndex items, then collects up to MAX_EXPAND children; adds a [More...] node when the page is full and more items remain. * Infinite-loop guard: caps iterations at min(startIndex+51, 10000). - Add helpers (internal static for testability): ExecuteCustomListBody, SubstituteLocalVars, ApplyExecToLocalVars, FormatCustomListItemName ($i / {$i} in names with word-boundary guard to avoid corrupting tokens like $item), CustomListLoopContext (shared mutable loop state), s_execAssignment static Regex (with =(?!=) to avoid matching == operators), s_execIncrDecr static Regex for ++/-- shorthand, s_dollarI static Regex (\$i\b) for safe bare-$i replacement. NatvisFormatSpecifierTest.cs: - 25 new unit tests covering SubstituteLocalVars (empty, no-vars, single substitution, word-boundary, multi-var, parens), ApplyExecToLocalVars (assignment, unknown LHS, empty, counter increment, == not matched, prefix/postfix ++/--, unknown var with ++, whitespace tolerance), and FormatCustomListItemName (null template, {$i}, bare $i, no special tokens, local-var substitution). --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 367 +++++++++++++++++- .../Natvis.Impl/NatvisXsdTypes.cs | 255 +++++++++++- .../NatvisFormatSpecifierTest.cs | 192 +++++++++ 3 files changed, 798 insertions(+), 16 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index bc22f5291..061c8f457 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -249,6 +249,16 @@ public VisualizerInfo(VisualizerType viz, TypeName name) private static readonly Regex s_intrinsicCallPattern = new Regex(@"\b(\w+)\s*\("); // Matches the leading "0x " address that GDB/LLDB prepends when displaying a string pointer value. private static readonly Regex s_addressPrefix = new Regex(@"^0x[0-9a-fA-F]+\s+"); + // Matches "varName = rhs" in a CustomListItems expression to detect local-variable assignments. + // The negative look-ahead (?!=) prevents matching "==" comparison operators. + private static readonly Regex s_execAssignment = new Regex(@"^\s*(\w+)\s*=(?!=)\s*(.+)$", RegexOptions.Singleline | RegexOptions.Compiled); + // Matches the bare "$i" token (word boundary) in a CustomListItems Name template. + // The {$i} form is matched first with a plain Replace; this regex handles bare "$i" + // with a word-boundary guard so that e.g. "$item" is not corrupted. + private static readonly Regex s_dollarI = new Regex(@"\$i\b", RegexOptions.Compiled); + // Matches increment/decrement shorthand in : ++i, i++, --i, i-- + // Groups: (1) prefix-op (2) prefix-varname | (3) postfix-varname (4) postfix-op + private static readonly Regex s_execIncrDecr = new Regex(@"^\s*(?:(\+\+|--)(\w+)|(\w+)(\+\+|--))\s*$", RegexOptions.Compiled); private List _typeVisualizers; private DebuggedProcess _process; private HostConfigurationStore _configStore; @@ -990,14 +1000,74 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable, s } else if (i is CustomListItemsType) { - CustomListItemsType item = (CustomListItemsType)i; - // IncludeView/ExcludeView: skip this block if the current view doesn't match. - // The loop execution itself will be added in a follow-up (CustomListItems step). - if (!IsIncludeViewMatch(item.IncludeView, currentView)) - continue; - if (IsExcludeViewMatch(item.ExcludeView, currentView)) - continue; - // CustomListItems loop body not yet implemented — children not emitted. + CustomListItemsType customList = (CustomListItemsType)i; + if (!IsIncludeViewMatch(customList.IncludeView, currentView)) continue; + if (IsExcludeViewMatch(customList.ExcludeView, currentView)) continue; + if (!EvalCondition(customList.Condition, variable, visualizer.ScopedNames, visualizer.Intrinsics)) continue; + if (customList.Loop == null || customList.Loop.Length == 0) continue; + + // Build the natvis local-variable table from elements. + // Each entry maps the declared name to its current expression string; expressions are + // substituted in-place whenever the name appears in subsequent loop-body expressions. + var localVars = new Dictionary(StringComparer.Ordinal); + if (customList.Items != null) + { + foreach (var v in customList.Items) + { + if (string.IsNullOrEmpty(v.Name)) continue; + string initVal = v.InitialValue ?? "0"; + // Resolve field names, template parameters and intrinsics in the initial value. + localVars[v.Name] = ReplaceNamesInExpression(initVal, variable, visualizer.ScopedNames, visualizer.Intrinsics); + } + } + + // Optional element provides an upper bound for children (and drives pagination). + uint totalSize = uint.MaxValue; + if (customList.Items1 != null) + { + foreach (var sz in customList.Items1) + { + if (!EvalCondition(sz.Condition, variable, visualizer.ScopedNames, visualizer.Intrinsics)) continue; + try + { + string szExpr = SubstituteLocalVars(sz.Value?.Trim() ?? "0", localVars); + string szVal = GetExpressionValue(szExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics); + totalSize = MICore.Debugger.ParseUint(szVal, throwOnError: false); + } + catch (Exception) { /* leave totalSize as MaxValue so Break drives termination */ } + break; + } + } + + uint startIndex = 0; + if (variable is PaginatedVisualizerWrapper pvwCLI) + startIndex = pvwCLI.StartIndex; + + var ctx = new CustomListLoopContext(startIndex, totalSize); + + foreach (var loop in customList.Loop) + { + if (loop?.Items == null || ctx.Done) continue; + + // Drive the loop: each call to ExecuteCustomListBody runs one full pass + // through the loop body (Break -> Item(s) -> Exec, in document order). + // The limit must cover fast-forwarding through startIndex items plus one + // page of MAX_EXPAND items, capped to avoid runaway loops. + long maxIter = Math.Min((long)startIndex + MAX_EXPAND + 1, 10000); + for (long iter = 0; !ctx.Done && ctx.GlobalIndex < ctx.TotalSize && iter < maxIter; iter++) + { + // While-guard: stop if the loop condition is false. + if (!string.IsNullOrEmpty(loop.Condition)) + { + string loopCond = SubstituteLocalVars(loop.Condition, localVars); + if (!EvalCondition(loopCond, variable, visualizer.ScopedNames, visualizer.Intrinsics)) + break; + } + bool progress = ExecuteCustomListBody(loop.Items, ctx, variable, visualizer, localVars, children); + if (!progress && !ctx.Done) + break; // no items emitted and no break — avoid infinite loop + } + } } } if (!(variable is VisualizerWrapper) && !expandType.HideRawView) // don't stack wrappers, and respect HideRawView @@ -1923,6 +1993,287 @@ private string GetDisplayNameFromArrayIndex(uint arrayIndex, int rank, uint[] di return displayName.ToString(); } + // ---- CustomListItems execution helpers ---------------------------------- + + /// + /// Mutable state shared across one invocation of the CustomListItems loop engine. + /// + private sealed class CustomListLoopContext + { + /// Total children emitted across all iterations ($i counter). + public uint GlobalIndex; + /// Children added to the current page. + public uint Emitted; + /// Pagination start (0 for the first page). + public readonly uint StartIndex; + /// Maximum total children expected (from <Size>, or uint.MaxValue). + public readonly uint TotalSize; + /// Set to true when a <Break> fires or the page limit is reached. + public bool Done; + + public CustomListLoopContext(uint startIndex, uint totalSize) + { + StartIndex = startIndex; + TotalSize = totalSize; + } + } + + /// + /// Executes one pass through a loop-body element sequence (Break / Item / Exec / If / Else). + /// Returns true if at least one Item or nested body was processed, false if the pass produced + /// no observable effect (used to detect infinite-loop conditions). + /// + private bool ExecuteCustomListBody( + object[] body, + CustomListLoopContext ctx, + IVariableInformation variable, + VisualizerInfo visualizer, + Dictionary localVars, + List children) + { + bool progress = false; + + for (int idx = 0; idx < body.Length && !ctx.Done; idx++) + { + var elem = body[idx]; + + if (elem is CustomListBreakType br) + { + // : stop the loop when the condition holds. + if (string.IsNullOrEmpty(br.Condition)) + { + ctx.Done = true; + break; + } + string condExpr = SubstituteLocalVars(br.Condition, localVars); + if (EvalCondition(condExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics)) + ctx.Done = true; + } + else if (elem is CustomListLoopItemType li) + { + // expr: emit a child variable. + if (li.Condition != null) + { + string condExpr = SubstituteLocalVars(li.Condition, localVars); + if (!EvalCondition(condExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics)) + continue; + } + + if (ctx.GlobalIndex >= ctx.StartIndex && ctx.Emitted < MAX_EXPAND) + { + string rawExpr = SubstituteLocalVars(li.Value?.Trim() ?? "", localVars); + string processedExpr = ReplaceNamesInExpression(rawExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics); + string name = FormatCustomListItemName(li.Name, ctx.GlobalIndex, localVars); + var childVar = new VariableInformation(processedExpr, variable, _process.Engine, name); + childVar.SyncEval(); + children.Add(childVar); + ctx.Emitted++; + } + ctx.GlobalIndex++; + progress = true; + + // Check whether the page is now full. + if (ctx.Emitted >= MAX_EXPAND && ctx.GlobalIndex < ctx.TotalSize) + { + children.Add(new PaginatedVisualizerWrapper( + ResourceStrings.MoreView, _process.Engine, variable, + visualizer, isVisualizerView: true, ctx.StartIndex + MAX_EXPAND)); + ctx.Done = true; + } + } + else if (elem is CustomListExecType exec) + { + // varName = expr: update a local variable. + if (exec.Condition != null) + { + string condExpr = SubstituteLocalVars(exec.Condition, localVars); + if (!EvalCondition(condExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics)) + continue; + } + string execExpr = SubstituteLocalVars(exec.Value?.Trim() ?? "", localVars); + string updatedVar = ApplyExecToLocalVars(execExpr, localVars); + // Normalise the stored expression to prevent unbounded growth across iterations. + // After each i++, the expression would otherwise grow as "(((0)+1)+1)+1...". + // Evaluate the new expression and replace it with the scalar result so that + // each iteration starts from a compact literal (same principle as intrinsic-eval + // caching). Skip normalisation for pointer values (starts with "0x") — those + // must remain as expressions, not substituted as address literals. + if (updatedVar != null) + { + try + { + string normalized = GetExpressionValue(localVars[updatedVar], variable, visualizer.ScopedNames, visualizer.Intrinsics); + if (!string.IsNullOrEmpty(normalized) && + !normalized.TrimStart().StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + localVars[updatedVar] = normalized; + } + } + catch (Exception) { /* keep the expression as-is if evaluation fails */ } + } + progress = true; // Exec advances loop state (e.g. iSpan++) even when no Item is emitted + } + else if (elem is CustomListIfType ifElem) + { + // optionally followed by any number of s and a final . + // Execute the first branch whose condition holds; consume all siblings. + string condExpr = SubstituteLocalVars(ifElem.Condition ?? "", localVars); + bool taken = !string.IsNullOrEmpty(condExpr) && + EvalCondition(condExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics); + + if (taken && ifElem.Items != null) + progress |= ExecuteCustomListBody(ifElem.Items, ctx, variable, visualizer, localVars, children); + + // Consume any immediately following elements. + while (idx + 1 < body.Length && body[idx + 1] is CustomListElseIfType elseIfElem) + { + idx++; + if (!taken) + { + string eiCond = SubstituteLocalVars(elseIfElem.Condition ?? "", localVars); + bool eiTaken = !string.IsNullOrEmpty(eiCond) && + EvalCondition(eiCond, variable, visualizer.ScopedNames, visualizer.Intrinsics); + if (eiTaken && elseIfElem.Items != null) + { + progress |= ExecuteCustomListBody(elseIfElem.Items, ctx, variable, visualizer, localVars, children); + taken = true; + } + } + } + + // Consume an immediately following element. + if (idx + 1 < body.Length && body[idx + 1] is CustomListElseType elseElem) + { + idx++; + if (!taken && elseElem.Items != null) + progress |= ExecuteCustomListBody(elseElem.Items, ctx, variable, visualizer, localVars, children); + } + } + else if (elem is CustomListLoopType nestedLoop) + { + // Nested : drive it like the top-level loop. + if (nestedLoop.Items != null) + { + // Cap iterations at ctx.StartIndex + MAX_EXPAND + 1: that is the highest + // GlobalIndex at which we could still emit or detect the page-full sentinel, + // so any further iteration would be dead work. The hard cap of 10 000 guards + // against infinite loops in malformed natvis where TotalSize is not set. + long maxInner = Math.Min((long)ctx.StartIndex + MAX_EXPAND + 1, 10000); + for (long iter = 0; !ctx.Done && ctx.GlobalIndex < ctx.TotalSize && iter < maxInner; iter++) + { + // While-guard: stop if the loop condition is false. + if (!string.IsNullOrEmpty(nestedLoop.Condition)) + { + string loopCond = SubstituteLocalVars(nestedLoop.Condition, localVars); + if (!EvalCondition(loopCond, variable, visualizer.ScopedNames, visualizer.Intrinsics)) + break; + } + bool innerProgress = ExecuteCustomListBody(nestedLoop.Items, ctx, variable, visualizer, localVars, children); + if (!innerProgress && !ctx.Done) break; + progress = true; + } + } + } + } + + return progress; + } + + /// + /// Substitutes natvis local variable names in with their + /// current expression strings, using word-boundary matching to avoid partial replacements. + /// Each substituted value is wrapped in parentheses to preserve operator precedence. + /// + internal static string SubstituteLocalVars(string expression, Dictionary localVars) + { + if (string.IsNullOrEmpty(expression) || localVars == null || localVars.Count == 0) + return expression; + foreach (var kv in localVars) + { + expression = Regex.Replace( + expression, + @"\b" + Regex.Escape(kv.Key) + @"\b", + "(" + kv.Value + ")"); + } + return expression; + } + + /// + /// Attempts to parse as "varName = rhs" and, when the left-hand + /// side is a declared natvis local variable, updates its entry to the substituted RHS. + /// Expressions that do not match this pattern are silently ignored. + /// + /// + /// The name of the local variable that was updated, or null if nothing changed. + /// The caller can use this to normalise the stored expression (evaluate it and replace + /// with the scalar result) so that repeated increments do not cause unbounded growth. + /// + internal static string ApplyExecToLocalVars(string execExpr, Dictionary localVars) + { + if (string.IsNullOrEmpty(execExpr)) return null; + + // Check for increment/decrement shorthand: ++i, i++, --i, i-- + var mIncr = s_execIncrDecr.Match(execExpr); + if (mIncr.Success) + { + // prefix form: groups 1 (op) + 2 (varname); postfix form: groups 3 (varname) + 4 (op) + string varName = mIncr.Groups[2].Success ? mIncr.Groups[2].Value : mIncr.Groups[3].Value; + string op = mIncr.Groups[1].Success ? mIncr.Groups[1].Value : mIncr.Groups[4].Value; + if (localVars.ContainsKey(varName)) + { + localVars[varName] = SubstituteLocalVars(varName, localVars) + (op == "++" ? " + 1" : " - 1"); + return varName; + } + return null; + } + + // Check for simple assignment: varName = rhs + var m = s_execAssignment.Match(execExpr); + if (m.Success && localVars.ContainsKey(m.Groups[1].Value)) + { + string varName = m.Groups[1].Value; + string rhs = m.Groups[2].Value.Trim(); + localVars[varName] = SubstituteLocalVars(rhs, localVars); + return varName; + } + return null; + } + + /// + /// Formats the display name for a CustomListItems child. Replaces {$i} and + /// bare $i with , then substitutes any local variable + /// names. Falls back to [index] when is null. + /// + /// + /// The condition-passing item counter (ctx.GlobalIndex), which starts at 0 and + /// increments for every Item whose Condition passes, across all pages. This matches + /// the Visual Studio behaviour where $i is the absolute loop-item index, not a + /// page-relative offset. + /// + internal static string FormatCustomListItemName(string nameTemplate, uint index, Dictionary localVars) + { + if (string.IsNullOrEmpty(nameTemplate)) + return "[" + index.ToString(CultureInfo.InvariantCulture) + "]"; + + string indexStr = index.ToString(CultureInfo.InvariantCulture); + // Replace the {$i} token first (complete braced form), then bare $i with a + // word-boundary guard so that e.g. "$item" in a Name template is not corrupted. + string name = s_dollarI.Replace( + nameTemplate.Replace("{$i}", indexStr), + indexStr); + name = SubstituteLocalVars(name, localVars); + + // If {expr} tokens remain after substitution they would require evaluating against + // the debugger, which is not supported here. Fall back to [index] to avoid surfacing + // a failed expression evaluation string as the child name. + if (name.Contains('{')) + return "[" + index.ToString(CultureInfo.InvariantCulture) + "]"; + + return name; + } + + // ---- End CustomListItems execution helpers ------------------------------ + public void Dispose() { GC.SuppressFinalize(this); diff --git a/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs b/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs index 924225b69..b6084182b 100644 --- a/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs +++ b/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs @@ -514,15 +514,17 @@ public string Value { public partial class CustomListItemsType { private VariableType[] itemsField; - + private CustomListSizeType[] items1Field; - + private SkipType itemField; - + + private CustomListLoopType[] loopField; + private bool optionalField; - + private bool optionalFieldSpecified; - + private string conditionField; private string includeViewField; @@ -565,7 +567,18 @@ public SkipType Item { this.itemField = value; } } - + + /// + [System.Xml.Serialization.XmlElementAttribute("Loop")] + public CustomListLoopType[] Loop { + get { + return this.loopField; + } + set { + this.loopField = value; + } + } + /// [System.Xml.Serialization.XmlAttributeAttribute()] public bool Optional { @@ -576,7 +589,7 @@ public bool Optional { this.optionalField = value; } } - + /// [System.Xml.Serialization.XmlIgnoreAttribute()] public bool OptionalSpecified { @@ -587,7 +600,7 @@ public bool OptionalSpecified { this.optionalFieldSpecified = value; } } - + /// [System.Xml.Serialization.XmlAttributeAttribute()] public string Condition { @@ -644,6 +657,232 @@ public bool MaxItemsPerViewSpecified { } } + // ---- CustomListItems loop-body types ------------------------------------ + // + // These types represent the body of a element inside . + // The loop body is an ordered sequence of Item, Break, Exec, If, and Else elements. + // Nested Loop elements are also supported inside If/Else bodies. + + /// + /// Represents an <Item> element inside a <CustomListItems> loop body. + /// Produces one child in the expanded view when its optional condition is true. + /// + [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] + [System.SerializableAttribute()] + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] + public partial class CustomListLoopItemType { + private string nameField; + private string conditionField; + private string valueField; + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Name { + get { return this.nameField; } + set { this.nameField = value; } + } + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Condition { + get { return this.conditionField; } + set { this.conditionField = value; } + } + + /// + [System.Xml.Serialization.XmlTextAttribute()] + public string Value { + get { return this.valueField; } + set { this.valueField = value; } + } + } + + /// + /// Represents a <Break> element inside a <CustomListItems> loop body. + /// The loop stops when the condition evaluates to true. + /// + [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] + [System.SerializableAttribute()] + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] + public partial class CustomListBreakType { + private string conditionField; + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Condition { + get { return this.conditionField; } + set { this.conditionField = value; } + } + } + + /// + /// Represents an <Exec> element inside a <CustomListItems> loop body. + /// The expression is parsed as an assignment to a declared natvis local variable + /// (e.g. "ptr = ptr->next") and updates that variable's current expression. + /// + [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] + [System.SerializableAttribute()] + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] + public partial class CustomListExecType { + private string conditionField; + private string valueField; + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Condition { + get { return this.conditionField; } + set { this.conditionField = value; } + } + + /// + [System.Xml.Serialization.XmlTextAttribute()] + public string Value { + get { return this.valueField; } + set { this.valueField = value; } + } + } + + /// + /// Represents an <If> element inside a <CustomListItems> loop body. + /// The body is executed only when Condition is true; immediately following + /// <ElseIf> and <Else> elements provide alternative branches. + /// + [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] + [System.SerializableAttribute()] + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] + public partial class CustomListIfType { + private string conditionField; + private object[] itemsField; + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Condition { + get { return this.conditionField; } + set { this.conditionField = value; } + } + + /// + [System.Xml.Serialization.XmlElementAttribute("Item", typeof(CustomListLoopItemType))] + [System.Xml.Serialization.XmlElementAttribute("Break", typeof(CustomListBreakType))] + [System.Xml.Serialization.XmlElementAttribute("Exec", typeof(CustomListExecType))] + [System.Xml.Serialization.XmlElementAttribute("If", typeof(CustomListIfType))] + [System.Xml.Serialization.XmlElementAttribute("ElseIf", typeof(CustomListElseIfType))] + [System.Xml.Serialization.XmlElementAttribute("Else", typeof(CustomListElseType))] + [System.Xml.Serialization.XmlElementAttribute("Loop", typeof(CustomListLoopType))] + public object[] Items { + get { return this.itemsField; } + set { this.itemsField = value; } + } + } + + /// + /// Represents an <ElseIf> element that immediately follows an <If> or + /// another <ElseIf>. Its body is executed when all preceding branch conditions + /// were false and this Condition holds. + /// + [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] + [System.SerializableAttribute()] + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] + public partial class CustomListElseIfType { + private string conditionField; + private object[] itemsField; + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Condition { + get { return this.conditionField; } + set { this.conditionField = value; } + } + + /// + [System.Xml.Serialization.XmlElementAttribute("Item", typeof(CustomListLoopItemType))] + [System.Xml.Serialization.XmlElementAttribute("Break", typeof(CustomListBreakType))] + [System.Xml.Serialization.XmlElementAttribute("Exec", typeof(CustomListExecType))] + [System.Xml.Serialization.XmlElementAttribute("If", typeof(CustomListIfType))] + [System.Xml.Serialization.XmlElementAttribute("ElseIf", typeof(CustomListElseIfType))] + [System.Xml.Serialization.XmlElementAttribute("Else", typeof(CustomListElseType))] + [System.Xml.Serialization.XmlElementAttribute("Loop", typeof(CustomListLoopType))] + public object[] Items { + get { return this.itemsField; } + set { this.itemsField = value; } + } + } + + /// + /// Represents an <Else> element that immediately follows an <If> or + /// <ElseIf> element. Its body is executed when all preceding branch conditions + /// were false. + /// + [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] + [System.SerializableAttribute()] + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] + public partial class CustomListElseType { + private object[] itemsField; + + /// + [System.Xml.Serialization.XmlElementAttribute("Item", typeof(CustomListLoopItemType))] + [System.Xml.Serialization.XmlElementAttribute("Break", typeof(CustomListBreakType))] + [System.Xml.Serialization.XmlElementAttribute("Exec", typeof(CustomListExecType))] + [System.Xml.Serialization.XmlElementAttribute("If", typeof(CustomListIfType))] + [System.Xml.Serialization.XmlElementAttribute("ElseIf", typeof(CustomListElseIfType))] + [System.Xml.Serialization.XmlElementAttribute("Else", typeof(CustomListElseType))] + [System.Xml.Serialization.XmlElementAttribute("Loop", typeof(CustomListLoopType))] + public object[] Items { + get { return this.itemsField; } + set { this.itemsField = value; } + } + } + + /// + /// Represents a <Loop> element inside <CustomListItems> (or nested in If/ElseIf/Else). + /// The optional Condition attribute acts as a while-guard: the loop stops as soon as it + /// evaluates to false. The body is also stopped by a <Break> element or when the + /// size limit is reached. + /// + [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] + [System.SerializableAttribute()] + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] + public partial class CustomListLoopType { + private string conditionField; + private object[] itemsField; + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Condition { + get { return this.conditionField; } + set { this.conditionField = value; } + } + + /// + [System.Xml.Serialization.XmlElementAttribute("Item", typeof(CustomListLoopItemType))] + [System.Xml.Serialization.XmlElementAttribute("Break", typeof(CustomListBreakType))] + [System.Xml.Serialization.XmlElementAttribute("Exec", typeof(CustomListExecType))] + [System.Xml.Serialization.XmlElementAttribute("If", typeof(CustomListIfType))] + [System.Xml.Serialization.XmlElementAttribute("ElseIf", typeof(CustomListElseIfType))] + [System.Xml.Serialization.XmlElementAttribute("Else", typeof(CustomListElseType))] + [System.Xml.Serialization.XmlElementAttribute("Loop", typeof(CustomListLoopType))] + public object[] Items { + get { return this.itemsField; } + set { this.itemsField = value; } + } + } + + // ---- End CustomListItems loop-body types --------------------------------- + /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] [System.SerializableAttribute()] diff --git a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs index b5fd7b2ee..186f72ab9 100644 --- a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs +++ b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs @@ -235,5 +235,197 @@ public void CleanAsciiStringValue_NoPrefix_Unchanged() { Assert.Equal("42", Natvis.CleanAsciiStringValue("42")); } + + // -- SubstituteLocalVars --------------------------------------------- + + [Fact] + public void SubstituteLocalVars_EmptyExpression_Unchanged() + { + Assert.Equal("", Natvis.SubstituteLocalVars("", new System.Collections.Generic.Dictionary { ["ptr"] = "head" })); + } + + [Fact] + public void SubstituteLocalVars_NoVars_Unchanged() + { + Assert.Equal("ptr->next", Natvis.SubstituteLocalVars("ptr->next", new System.Collections.Generic.Dictionary())); + } + + [Fact] + public void SubstituteLocalVars_SingleVar_Substituted() + { + var vars = new System.Collections.Generic.Dictionary { ["ptr"] = "m_head" }; + Assert.Equal("(m_head)->next", Natvis.SubstituteLocalVars("ptr->next", vars)); + } + + [Fact] + public void SubstituteLocalVars_WordBoundary_PartialNameNotReplaced() + { + // "ptrr" must not be replaced when the variable is "ptr" + var vars = new System.Collections.Generic.Dictionary { ["ptr"] = "m_head" }; + Assert.Equal("ptrr->next", Natvis.SubstituteLocalVars("ptrr->next", vars)); + } + + [Fact] + public void SubstituteLocalVars_MultipleVars_BothSubstituted() + { + var vars = new System.Collections.Generic.Dictionary + { + ["lo"] = "start", + ["hi"] = "end" + }; + Assert.Equal("(start) + (end)", Natvis.SubstituteLocalVars("lo + hi", vars)); + } + + [Fact] + public void SubstituteLocalVars_ParensPreservesPrecedence() + { + // Replacement is wrapped in () so "ptr->val * 2" becomes "((m_head)->val) * 2" + // after two substitution passes aren't needed here — single pass is enough. + var vars = new System.Collections.Generic.Dictionary { ["ptr"] = "m_head" }; + Assert.Equal("(m_head)->val", Natvis.SubstituteLocalVars("ptr->val", vars)); + } + + // -- ApplyExecToLocalVars -------------------------------------------- + + [Fact] + public void ApplyExecToLocalVars_SimpleAssignment_UpdatesVar() + { + var vars = new System.Collections.Generic.Dictionary { ["ptr"] = "m_head" }; + Natvis.ApplyExecToLocalVars("ptr = ptr->next", vars); + Assert.Equal("(m_head)->next", vars["ptr"]); + } + + [Fact] + public void ApplyExecToLocalVars_UnknownLhs_NoChange() + { + var vars = new System.Collections.Generic.Dictionary { ["ptr"] = "m_head" }; + Natvis.ApplyExecToLocalVars("other = 0", vars); + // "other" is not a declared variable; dict should be unchanged + Assert.Equal("m_head", vars["ptr"]); + Assert.False(vars.ContainsKey("other")); + } + + [Fact] + public void ApplyExecToLocalVars_EmptyExpression_NoChange() + { + var vars = new System.Collections.Generic.Dictionary { ["ptr"] = "m_head" }; + Natvis.ApplyExecToLocalVars("", vars); + Assert.Equal("m_head", vars["ptr"]); + } + + [Fact] + public void ApplyExecToLocalVars_CounterIncrement_UpdatesVar() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "0" }; + Natvis.ApplyExecToLocalVars("i = i + 1", vars); + Assert.Equal("(0) + 1", vars["i"]); + } + + // Assignment must not match == comparison operator (regression guard) + [Fact] + public void ApplyExecToLocalVars_EqualityComparison_NoChange() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "5" }; + Natvis.ApplyExecToLocalVars("i == 1", vars); + Assert.Equal("5", vars["i"]); + } + + // -- ApplyExecToLocalVars — increment/decrement ----------------------- + + [Fact] + public void ApplyExecToLocalVars_PrefixIncrement_UpdatesVar() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "3" }; + Natvis.ApplyExecToLocalVars("++i", vars); + Assert.Equal("(3) + 1", vars["i"]); + } + + [Fact] + public void ApplyExecToLocalVars_PostfixIncrement_UpdatesVar() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "3" }; + Natvis.ApplyExecToLocalVars("i++", vars); + Assert.Equal("(3) + 1", vars["i"]); + } + + [Fact] + public void ApplyExecToLocalVars_PrefixDecrement_UpdatesVar() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "3" }; + Natvis.ApplyExecToLocalVars("--i", vars); + Assert.Equal("(3) - 1", vars["i"]); + } + + [Fact] + public void ApplyExecToLocalVars_PostfixDecrement_UpdatesVar() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "3" }; + Natvis.ApplyExecToLocalVars("i--", vars); + Assert.Equal("(3) - 1", vars["i"]); + } + + [Fact] + public void ApplyExecToLocalVars_IncrUnknownVar_NoChange() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "3" }; + Natvis.ApplyExecToLocalVars("++j", vars); + Assert.Equal("3", vars["i"]); + Assert.False(vars.ContainsKey("j")); + } + + [Fact] + public void ApplyExecToLocalVars_IncrWithSpaces_UpdatesVar() + { + // Whitespace around the operator/operand must be tolerated. + var vars = new System.Collections.Generic.Dictionary { ["i"] = "0" }; + Natvis.ApplyExecToLocalVars(" i++ ", vars); + Assert.Equal("(0) + 1", vars["i"]); + } + + // -- FormatCustomListItemName ---------------------------------------- + + [Fact] + public void FormatCustomListItemName_NullTemplate_ReturnsBracketedIndex() + { + Assert.Equal("[0]", Natvis.FormatCustomListItemName(null, 0, new System.Collections.Generic.Dictionary())); + Assert.Equal("[42]", Natvis.FormatCustomListItemName(null, 42, new System.Collections.Generic.Dictionary())); + } + + [Fact] + public void FormatCustomListItemName_BracedDollarI_ReplacedWithIndex() + { + Assert.Equal("[7]", Natvis.FormatCustomListItemName("[{$i}]", 7, new System.Collections.Generic.Dictionary())); + } + + [Fact] + public void FormatCustomListItemName_BareDollarI_ReplacedWithIndex() + { + Assert.Equal("item_3", Natvis.FormatCustomListItemName("item_$i", 3, new System.Collections.Generic.Dictionary())); + } + + [Fact] + public void FormatCustomListItemName_NoSpecialTokens_Unchanged() + { + Assert.Equal("key", Natvis.FormatCustomListItemName("key", 5, new System.Collections.Generic.Dictionary())); + } + + [Fact] + public void FormatCustomListItemName_LocalVar_Substituted() + { + // A local variable name that appears in the Name template is substituted. + var vars = new System.Collections.Generic.Dictionary { ["node"] = "m_head" }; + Assert.Equal("[(m_head)]", Natvis.FormatCustomListItemName("[node]", 0, vars)); + } + + [Fact] + public void FormatCustomListItemName_ExprToken_FallsBackToIndex() + { + // {expr} tokens that survive local-var substitution require debugger evaluation, + // which is not available here. The method must fall back to [index] rather than + // surfacing the raw expression text (or a debugger error string) as the child name. + var vars = new System.Collections.Generic.Dictionary { ["iSpan"] = "0" }; + // After substituting iSpan, "[{getKey((0), 0)}]" still contains '{' -- fall back. + Assert.Equal("[2]", Natvis.FormatCustomListItemName("[{getKey(iSpan, 0)}]", 2, vars)); + } } }