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 01/15] 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 02/15] 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)); + } } } From d23e5a4e4bc8b38871be5b0e4f4160ddc42a0f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Mon, 22 Jun 2026 16:22:42 +0200 Subject: [PATCH 03/15] Natvis: rename CustomListItems loop-body types to match natvis.xsd Rename the loop-body types to the names used in the main natvis.xsd, and fix the element casing: CustomListLoopItemType -> CustomListItemType CustomListBreakType -> BreakType CustomListExecType -> ExecType CustomListIfType -> IfType CustomListElseType -> ElseType CustomListLoopType -> LoopType element name "ElseIf" -> "Elseif" IfType change: the xsd declares , so it shares IfType with instead of having its own type. A single object[] member cannot map two element names ("If" and "Elseif") to the same type; XmlSerializer throws at construction. The generated form of the schema solves this with an XmlChoiceIdentifier, so this commit does the same: - reuses IfType (the separate CustomListElseIfType is removed). - IfType/ElseType/LoopType gain a parallel ItemsChoiceType[] ItemsElementName array, declared via [XmlChoiceIdentifier], that records which element name produced each Items entry. - ExecuteCustomListBody now receives that choice array and uses it to tell an from an , since both deserialize to IfType. This is what regenerating NatvisXsdTypes.cs from natvis.xsd would produce, keeping the hand-maintained file consistent with the schema. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 42 +++-- .../Natvis.Impl/NatvisXsdTypes.cs | 166 +++++++++++------- 2 files changed, 128 insertions(+), 80 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 061c8f457..454682d06 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -1063,7 +1063,7 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable, s if (!EvalCondition(loopCond, variable, visualizer.ScopedNames, visualizer.Intrinsics)) break; } - bool progress = ExecuteCustomListBody(loop.Items, ctx, variable, visualizer, localVars, children); + bool progress = ExecuteCustomListBody(loop.Items, loop.ItemsElementName, ctx, variable, visualizer, localVars, children); if (!progress && !ctx.Done) break; // no items emitted and no break — avoid infinite loop } @@ -2025,6 +2025,7 @@ public CustomListLoopContext(uint startIndex, uint totalSize) /// private bool ExecuteCustomListBody( object[] body, + ItemsChoiceType[] choices, CustomListLoopContext ctx, IVariableInformation variable, VisualizerInfo visualizer, @@ -2033,11 +2034,16 @@ private bool ExecuteCustomListBody( { bool progress = false; + // and both deserialize to IfType; the parallel choice array records the + // original element name so the two can be told apart (defaults to If if unavailable). + ItemsChoiceType ChoiceAt(int i) => + (choices != null && i < choices.Length) ? choices[i] : ItemsChoiceType.If; + for (int idx = 0; idx < body.Length && !ctx.Done; idx++) { var elem = body[idx]; - if (elem is CustomListBreakType br) + if (elem is BreakType br) { // : stop the loop when the condition holds. if (string.IsNullOrEmpty(br.Condition)) @@ -2049,7 +2055,7 @@ private bool ExecuteCustomListBody( if (EvalCondition(condExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics)) ctx.Done = true; } - else if (elem is CustomListLoopItemType li) + else if (elem is CustomListItemType li) { // expr: emit a child variable. if (li.Condition != null) @@ -2081,7 +2087,7 @@ private bool ExecuteCustomListBody( ctx.Done = true; } } - else if (elem is CustomListExecType exec) + else if (elem is ExecType exec) { // varName = expr: update a local variable. if (exec.Condition != null) @@ -2113,19 +2119,27 @@ private bool ExecuteCustomListBody( } progress = true; // Exec advances loop state (e.g. iSpan++) even when no Item is emitted } - else if (elem is CustomListIfType ifElem) + else if (elem is IfType ifElem) { - // optionally followed by any number of s and a final . + // optionally followed by any number of s and a final . // Execute the first branch whose condition holds; consume all siblings. + // shares IfType with , so they are distinguished via the choice + // array. A standalone (no preceding ) is malformed: warn and skip. + if (ChoiceAt(idx) == ItemsChoiceType.Elseif) + { + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, "CustomListItems: without a preceding ."); + continue; + } + 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); + progress |= ExecuteCustomListBody(ifElem.Items, ifElem.ItemsElementName, ctx, variable, visualizer, localVars, children); - // Consume any immediately following elements. - while (idx + 1 < body.Length && body[idx + 1] is CustomListElseIfType elseIfElem) + // Consume any immediately following elements (IfType with choice == Elseif). + while (idx + 1 < body.Length && body[idx + 1] is IfType elseIfElem && ChoiceAt(idx + 1) == ItemsChoiceType.Elseif) { idx++; if (!taken) @@ -2135,21 +2149,21 @@ private bool ExecuteCustomListBody( EvalCondition(eiCond, variable, visualizer.ScopedNames, visualizer.Intrinsics); if (eiTaken && elseIfElem.Items != null) { - progress |= ExecuteCustomListBody(elseIfElem.Items, ctx, variable, visualizer, localVars, children); + progress |= ExecuteCustomListBody(elseIfElem.Items, elseIfElem.ItemsElementName, ctx, variable, visualizer, localVars, children); taken = true; } } } // Consume an immediately following element. - if (idx + 1 < body.Length && body[idx + 1] is CustomListElseType elseElem) + if (idx + 1 < body.Length && body[idx + 1] is ElseType elseElem) { idx++; if (!taken && elseElem.Items != null) - progress |= ExecuteCustomListBody(elseElem.Items, ctx, variable, visualizer, localVars, children); + progress |= ExecuteCustomListBody(elseElem.Items, elseElem.ItemsElementName, ctx, variable, visualizer, localVars, children); } } - else if (elem is CustomListLoopType nestedLoop) + else if (elem is LoopType nestedLoop) { // Nested : drive it like the top-level loop. if (nestedLoop.Items != null) @@ -2168,7 +2182,7 @@ private bool ExecuteCustomListBody( if (!EvalCondition(loopCond, variable, visualizer.ScopedNames, visualizer.Intrinsics)) break; } - bool innerProgress = ExecuteCustomListBody(nestedLoop.Items, ctx, variable, visualizer, localVars, children); + bool innerProgress = ExecuteCustomListBody(nestedLoop.Items, nestedLoop.ItemsElementName, ctx, variable, visualizer, localVars, children); if (!innerProgress && !ctx.Done) break; progress = true; } diff --git a/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs b/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs index b6084182b..fcc69526e 100644 --- a/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs +++ b/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs @@ -519,7 +519,7 @@ public partial class CustomListItemsType { private SkipType itemField; - private CustomListLoopType[] loopField; + private LoopType[] loopField; private bool optionalField; @@ -570,7 +570,7 @@ public SkipType Item { /// [System.Xml.Serialization.XmlElementAttribute("Loop")] - public CustomListLoopType[] Loop { + public LoopType[] Loop { get { return this.loopField; } @@ -660,8 +660,14 @@ 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. + // The loop body is an ordered sequence of Item, Break, Exec, If, Elseif, Else and + // Loop elements. Nested Loop elements are also supported inside If/Elseif/Else bodies. + // + // Per the natvis schema, shares IfType with (it has the same content + // model). The XmlSerializer cannot map two element names to one type on its own, so the + // Items arrays use an XmlChoiceIdentifier (the parallel ItemsElementName array of + // ItemsChoiceType) to record which element name produced each entry; the loop engine + // reads it to tell an from an . /// /// Represents an <Item> element inside a <CustomListItems> loop body. @@ -672,7 +678,7 @@ public bool MaxItemsPerViewSpecified { [System.Diagnostics.DebuggerStepThroughAttribute()] [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] - public partial class CustomListLoopItemType { + public partial class CustomListItemType { private string nameField; private string conditionField; private string valueField; @@ -708,7 +714,7 @@ public string Value { [System.Diagnostics.DebuggerStepThroughAttribute()] [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] - public partial class CustomListBreakType { + public partial class BreakType { private string conditionField; /// @@ -729,7 +735,7 @@ public string Condition { [System.Diagnostics.DebuggerStepThroughAttribute()] [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] - public partial class CustomListExecType { + public partial class ExecType { private string conditionField; private string valueField; @@ -751,17 +757,19 @@ public string 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. + /// <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 { + public partial class IfType { private string conditionField; private object[] itemsField; + private ItemsChoiceType[] itemsElementNameField; + /// [System.Xml.Serialization.XmlAttributeAttribute()] public string Condition { @@ -770,57 +778,32 @@ public string Condition { } /// - [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))] + [System.Xml.Serialization.XmlElementAttribute("Item", typeof(CustomListItemType))] + [System.Xml.Serialization.XmlElementAttribute("Break", typeof(BreakType))] + [System.Xml.Serialization.XmlElementAttribute("Exec", typeof(ExecType))] + [System.Xml.Serialization.XmlElementAttribute("If", typeof(IfType))] + [System.Xml.Serialization.XmlElementAttribute("Elseif", typeof(IfType))] + [System.Xml.Serialization.XmlElementAttribute("Else", typeof(ElseType))] + [System.Xml.Serialization.XmlElementAttribute("Loop", typeof(LoopType))] + [System.Xml.Serialization.XmlChoiceIdentifierAttribute("ItemsElementName")] 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; } + // Parallel to Items: records which element name (If vs Elseif, etc.) produced each + // entry, since the schema maps both and to the same IfType. + [System.Xml.Serialization.XmlIgnoreAttribute()] + public ItemsChoiceType[] ItemsElementName { + get { return this.itemsElementNameField; } + set { this.itemsElementNameField = value; } } } /// /// Represents an <Else> element that immediately follows an <If> or - /// <ElseIf> element. Its body is executed when all preceding branch conditions + /// <Elseif> element. Its body is executed when all preceding branch conditions /// were false. /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] @@ -828,25 +811,37 @@ public object[] Items { [System.Diagnostics.DebuggerStepThroughAttribute()] [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] - public partial class CustomListElseType { + public partial class ElseType { private object[] itemsField; + private ItemsChoiceType[] itemsElementNameField; + /// - [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))] + [System.Xml.Serialization.XmlElementAttribute("Item", typeof(CustomListItemType))] + [System.Xml.Serialization.XmlElementAttribute("Break", typeof(BreakType))] + [System.Xml.Serialization.XmlElementAttribute("Exec", typeof(ExecType))] + [System.Xml.Serialization.XmlElementAttribute("If", typeof(IfType))] + [System.Xml.Serialization.XmlElementAttribute("Elseif", typeof(IfType))] + [System.Xml.Serialization.XmlElementAttribute("Else", typeof(ElseType))] + [System.Xml.Serialization.XmlElementAttribute("Loop", typeof(LoopType))] + [System.Xml.Serialization.XmlChoiceIdentifierAttribute("ItemsElementName")] public object[] Items { get { return this.itemsField; } set { this.itemsField = value; } } + + /// + // Parallel to Items: records which element name (If vs Elseif, etc.) produced each + // entry, since the schema maps both and to the same IfType. + [System.Xml.Serialization.XmlIgnoreAttribute()] + public ItemsChoiceType[] ItemsElementName { + get { return this.itemsElementNameField; } + set { this.itemsElementNameField = value; } + } } /// - /// Represents a <Loop> element inside <CustomListItems> (or nested in If/ElseIf/Else). + /// 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. @@ -856,10 +851,12 @@ public object[] Items { [System.Diagnostics.DebuggerStepThroughAttribute()] [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] - public partial class CustomListLoopType { + public partial class LoopType { private string conditionField; private object[] itemsField; + private ItemsChoiceType[] itemsElementNameField; + /// [System.Xml.Serialization.XmlAttributeAttribute()] public string Condition { @@ -868,17 +865,54 @@ public string Condition { } /// - [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))] + [System.Xml.Serialization.XmlElementAttribute("Item", typeof(CustomListItemType))] + [System.Xml.Serialization.XmlElementAttribute("Break", typeof(BreakType))] + [System.Xml.Serialization.XmlElementAttribute("Exec", typeof(ExecType))] + [System.Xml.Serialization.XmlElementAttribute("If", typeof(IfType))] + [System.Xml.Serialization.XmlElementAttribute("Elseif", typeof(IfType))] + [System.Xml.Serialization.XmlElementAttribute("Else", typeof(ElseType))] + [System.Xml.Serialization.XmlElementAttribute("Loop", typeof(LoopType))] + [System.Xml.Serialization.XmlChoiceIdentifierAttribute("ItemsElementName")] public object[] Items { get { return this.itemsField; } set { this.itemsField = value; } } + + /// + // Parallel to Items: records which element name (If vs Elseif, etc.) produced each + // entry, since the schema maps both and to the same IfType. + [System.Xml.Serialization.XmlIgnoreAttribute()] + public ItemsChoiceType[] ItemsElementName { + get { return this.itemsElementNameField; } + set { this.itemsElementNameField = value; } + } + } + + /// + /// Element-name discriminator for the loop-body Items arrays. Because the schema + /// maps both <If> and <Elseif> to IfType, the deserializer can no longer be + /// told apart by runtime type; this parallel enum (populated via XmlChoiceIdentifier) + /// records the original element name for each entry. Member names must match the element + /// names exactly. + /// + [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] + [System.SerializableAttribute()] + [System.Xml.Serialization.XmlTypeAttribute(IncludeInSchema=false, Namespace="http://schemas.microsoft.com/vstudio/debugger/natvis/2010")] + public enum ItemsChoiceType { + /// + Item, + /// + Break, + /// + Exec, + /// + If, + /// + Elseif, + /// + Else, + /// + Loop, } // ---- End CustomListItems loop-body types --------------------------------- From 9507c9276029ae8e32e5acbeddef97a2ec6a9cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Wed, 24 Jun 2026 10:14:56 +0200 Subject: [PATCH 04/15] Natvis: warn on unexpected loop-body elements, and warn+stop on failed normalization Two robustness fixes in the CustomListItems loop engine: - ExecuteCustomListBody now ends with an else branch that logs a warning naming any unrecognised loop-body element, instead of silently skipping it. - The normalization catch now logs a warning and stops the loop. We log and break in the second case because the normalization evaluates the loop variable we just updated; the loop body and its conditions use that same variable, so if it can't be evaluated the iteration can't continue meaningfully. Rather than press on with a variable we know is broken (which would emit error items or run to the iteration cap) we log the failure and stop the loop. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 454682d06..467f1b351 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -2115,7 +2115,15 @@ ItemsChoiceType ChoiceAt(int i) => localVars[updatedVar] = normalized; } } - catch (Exception) { /* keep the expression as-is if evaluation fails */ } + catch (Exception e) + { + // The normalization evaluates the variable we just updated. If that throws, + // the variable can't be evaluated -- and since the loop body and its + // conditions use the same variable, the iteration can't continue meaningfully. + // Log and stop the loop rather than emit error items or run to the cap. + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, "CustomListItems normalization failed; stopping loop: " + e.Message); + ctx.Done = true; + } } progress = true; // Exec advances loop state (e.g. iSpan++) even when no Item is emitted } @@ -2188,6 +2196,13 @@ ItemsChoiceType ChoiceAt(int i) => } } } + else + { + // Unrecognised loop-body element. This should not occur for natvis that + // validates against the schema (a stray without a preceding + // also lands here); log so unsupported/malformed elements are visible. + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, "CustomListItems loop body contains an unsupported element: " + elem?.GetType().Name); + } } return progress; From f14a0554975c57310db13fa0ef4d354670dd8085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Wed, 24 Jun 2026 14:25:33 +0200 Subject: [PATCH 05/15] Natvis: support multi-variable and fix assignment handling .natvis files shipped with VS often assign to several variables in one block (e.g. "++idx, ++statptr"), which we previously did not handle. - Multi-variable support: ApplyExecToLocalVars splits the text on top-level commas (commas inside parentheses/brackets are left intact, so "f(a, b)" is not split) and applies each assignment in turn, returning all updated variable names so each is normalised. - Warning: a non-empty segment we cannot apply (an unsupported expression such as "idx += 2" or a call, or an undeclared left-hand side) is now logged at Warning instead of being silently dropped. - Fix: the value was being substituted by SubstituteLocalVars *before* ApplyExecToLocalVars parsed it, turning "i = i + 1" into "(0) = (0) + 1" so the "(\w+)" left-hand-side match failed and the assignment was silently dropped; no assignment took effect at runtime (the unit tests passed only because they call the helper with raw text). The helper is now given the raw text and does the right-hand-side substitution itself. Tests cover multiple increments, multiple assignments in order, the returned name list, a comma inside parentheses, a trailing empty segment, unhandled segments reported for an unsupported form and an undeclared left-hand side, and SplitTopLevelCommas. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 98 +++++++++++++--- .../NatvisFormatSpecifierTest.cs | 107 ++++++++++++++++++ 2 files changed, 192 insertions(+), 13 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 467f1b351..ad962b8b8 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -2089,23 +2089,32 @@ ItemsChoiceType ChoiceAt(int i) => } else if (elem is ExecType exec) { - // varName = expr: update a local variable. + // var = expr (or "++var", or several + // comma-separated assignments): update one or more local variables. 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) + // Pass the raw text: ApplyExecToLocalVars detects the assigned variable + // name(s) and substitutes local vars on the right-hand side itself. A single + // may update several variables, comma-separated (e.g. "++idx, ++statptr"). + var updatedVars = ApplyExecToLocalVars(exec.Value?.Trim() ?? "", localVars, out List unhandledExec); + foreach (string seg in unhandledExec) { + // A segment we could not apply (unsupported form, or an undeclared + // left-hand side) would otherwise silently do nothing; log it so the + // omission is visible rather than producing a wrong/stuck traversal. + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, "CustomListItems segment not applied (unsupported expression or undeclared variable): " + seg); + } + foreach (string updatedVar in updatedVars) + { + // Normalise each updated variable to prevent unbounded growth across + // iterations: after each i++ the expression would otherwise grow as + // "(((0)+1)+1)+1...". Evaluate it and store the scalar result so each + // iteration starts from a compact literal. Skip pointer values ("0x...") + // — those must remain as expressions, not substituted as address literals. try { string normalized = GetExpressionValue(localVars[updatedVar], variable, visualizer.ScopedNames, visualizer.Intrinsics); @@ -2117,12 +2126,13 @@ ItemsChoiceType ChoiceAt(int i) => } catch (Exception e) { - // The normalization evaluates the variable we just updated. If that throws, + // The normalization evaluates a variable we just updated. If that throws, // the variable can't be evaluated -- and since the loop body and its // conditions use the same variable, the iteration can't continue meaningfully. // Log and stop the loop rather than emit error items or run to the cap. _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, "CustomListItems normalization failed; stopping loop: " + e.Message); ctx.Done = true; + break; } } progress = true; // Exec advances loop state (e.g. iSpan++) even when no Item is emitted @@ -2237,9 +2247,41 @@ internal static string SubstituteLocalVars(string expression, Dictionary - internal static string ApplyExecToLocalVars(string execExpr, Dictionary localVars) + internal static List ApplyExecToLocalVars(string execExpr, Dictionary localVars) + => ApplyExecToLocalVars(execExpr, localVars, out _); + + internal static List ApplyExecToLocalVars(string execExpr, Dictionary localVars, out List unhandled) { - if (string.IsNullOrEmpty(execExpr)) return null; + var updated = new List(); + unhandled = new List(); + if (string.IsNullOrEmpty(execExpr)) return updated; + + // A single may update several variables, comma-separated, e.g. + // "++idx, ++statptr" (common in the .natvis files shipped with VS). Split on + // top-level commas (not those inside parentheses/brackets) and apply each in order. + // Non-empty segments we cannot apply (unsupported form, or an undeclared left-hand + // side) are collected so the caller can log a warning rather than drop them silently. + foreach (string part in SplitTopLevelCommas(execExpr)) + { + if (string.IsNullOrWhiteSpace(part)) + continue; + string varName = ApplySingleExec(part, localVars); + if (varName != null) + updated.Add(varName); + else + unhandled.Add(part.Trim()); + } + return updated; + } + + /// + /// Applies one assignment from an <Exec> block to the local-variable table: + /// "varName = rhs", or the "++"/"--" shorthand. Returns the updated variable name, + /// or null when the segment is empty or its left-hand side is not a declared local. + /// + private static string ApplySingleExec(string execExpr, Dictionary localVars) + { + if (string.IsNullOrWhiteSpace(execExpr)) return null; // Check for increment/decrement shorthand: ++i, i++, --i, i-- var mIncr = s_execIncrDecr.Match(execExpr); @@ -2268,6 +2310,36 @@ internal static string ApplyExecToLocalVars(string execExpr, Dictionary + /// Splits an expression on top-level commas — commas not nested inside parentheses + /// or square brackets — so a multi-assignment <Exec> like "++idx, ++statptr" is + /// separated while a comma inside a call such as "f(a, b)" is left intact. + /// + internal static IEnumerable SplitTopLevelCommas(string expression) + { + if (string.IsNullOrEmpty(expression)) + yield break; + + int depth = 0; + int start = 0; + for (int i = 0; i < expression.Length; i++) + { + char c = expression[i]; + if (c == '(' || c == '[') + depth++; + else if (c == ')' || c == ']') + { + if (depth > 0) depth--; + } + else if (c == ',' && depth == 0) + { + yield return expression.Substring(start, i - start); + start = i + 1; + } + } + yield return expression.Substring(start); + } + /// /// Formats the display name for a CustomListItems child. Replaces {$i} and /// bare $i with , then substitutes any local variable diff --git a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs index 186f72ab9..68bdf71fc 100644 --- a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs +++ b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs @@ -427,5 +427,112 @@ public void FormatCustomListItemName_ExprToken_FallsBackToIndex() // After substituting iSpan, "[{getKey((0), 0)}]" still contains '{' -- fall back. Assert.Equal("[2]", Natvis.FormatCustomListItemName("[{getKey(iSpan, 0)}]", 2, vars)); } + + // -- ApplyExecToLocalVars — multiple assignments ---------------------- + + [Fact] + public void ApplyExecToLocalVars_MultipleIncrements_UpdatesAll() + { + // A single can update several variables, e.g. "++idx, ++statptr". + var vars = new System.Collections.Generic.Dictionary { ["idx"] = "0", ["statptr"] = "5" }; + Natvis.ApplyExecToLocalVars("++idx, ++statptr", vars); + Assert.Equal("(0) + 1", vars["idx"]); + Assert.Equal("(5) + 1", vars["statptr"]); + } + + [Fact] + public void ApplyExecToLocalVars_MultipleAssignments_UpdatesAllInOrder() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "0", ["j"] = "10" }; + Natvis.ApplyExecToLocalVars("i = i + 1, j = j - 1", vars); + Assert.Equal("(0) + 1", vars["i"]); + Assert.Equal("(10) - 1", vars["j"]); + } + + [Fact] + public void ApplyExecToLocalVars_MultipleAssignments_ReturnsAllUpdatedNames() + { + var vars = new System.Collections.Generic.Dictionary { ["idx"] = "0", ["statptr"] = "5" }; + var updated = Natvis.ApplyExecToLocalVars("++idx, ++statptr", vars); + Assert.Equal(2, updated.Count); + Assert.Contains("idx", updated); + Assert.Contains("statptr", updated); + } + + [Fact] + public void ApplyExecToLocalVars_CommaInsideParens_NotSplit() + { + // The comma inside max(...) must not split the assignment into two. + var vars = new System.Collections.Generic.Dictionary { ["i"] = "0" }; + var updated = Natvis.ApplyExecToLocalVars("i = max(i, 5)", vars); + Assert.Single(updated); + Assert.Equal("max((0), 5)", vars["i"]); + } + + [Fact] + public void ApplyExecToLocalVars_TrailingComma_EmptySegmentIgnored() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "0" }; + var updated = Natvis.ApplyExecToLocalVars("++i,", vars); + Assert.Single(updated); + Assert.Equal("(0) + 1", vars["i"]); + } + + // -- SplitTopLevelCommas ---------------------------------------------- + + [Fact] + public void SplitTopLevelCommas_TopLevel_Splits() + { + Assert.Equal(new[] { "a", "b", "c" }, System.Linq.Enumerable.ToArray(Natvis.SplitTopLevelCommas("a,b,c"))); + } + + [Fact] + public void SplitTopLevelCommas_InsideParens_NotSplit() + { + Assert.Equal(new[] { "f(a,b)", "c" }, System.Linq.Enumerable.ToArray(Natvis.SplitTopLevelCommas("f(a,b),c"))); + } + + [Fact] + public void SplitTopLevelCommas_InsideBrackets_NotSplit() + { + Assert.Equal(new[] { "arr[i,j]", "k" }, System.Linq.Enumerable.ToArray(Natvis.SplitTopLevelCommas("arr[i,j],k"))); + } + + [Fact] + public void SplitTopLevelCommas_NoComma_SingleSegment() + { + Assert.Equal(new[] { "ptr->next" }, System.Linq.Enumerable.ToArray(Natvis.SplitTopLevelCommas("ptr->next"))); + } + + // -- ApplyExecToLocalVars — unhandled segments are reported ------------ + + [Fact] + public void ApplyExecToLocalVars_UnsupportedSegment_ReportedAsUnhandled() + { + // A segment we cannot apply (here a function call) is reported as unhandled, + // while the recognised increment in the same is still applied. + var vars = new System.Collections.Generic.Dictionary { ["idx"] = "0" }; + var updated = Natvis.ApplyExecToLocalVars("++idx, frobnicate()", vars, out var unhandled); + Assert.Equal(new[] { "idx" }, System.Linq.Enumerable.ToArray(updated)); + Assert.Equal(new[] { "frobnicate()" }, System.Linq.Enumerable.ToArray(unhandled)); + Assert.Equal("(0) + 1", vars["idx"]); + } + + [Fact] + public void ApplyExecToLocalVars_UndeclaredLhs_ReportedAsUnhandled() + { + var vars = new System.Collections.Generic.Dictionary { ["i"] = "0" }; + Natvis.ApplyExecToLocalVars("other = 5", vars, out var unhandled); + Assert.Equal(new[] { "other = 5" }, System.Linq.Enumerable.ToArray(unhandled)); + } + + [Fact] + public void ApplyExecToLocalVars_TrailingComma_NotReportedAsUnhandled() + { + // An empty segment from a trailing comma must not be flagged as unhandled. + var vars = new System.Collections.Generic.Dictionary { ["i"] = "0" }; + Natvis.ApplyExecToLocalVars("++i,", vars, out var unhandled); + Assert.Empty(unhandled); + } } } From 3f171bb2384a5ddeb65cebfb82dc1925e55bd580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Wed, 24 Jun 2026 14:43:00 +0200 Subject: [PATCH 06/15] Natvis: share the loop driver between the top-level and nested The top-level loop driver (in ExpandVisualized) and the nested branch in ExecuteCustomListBody were near-identical copies of the same iteration logic: the iteration cap, the optional while-Condition guard, the per-pass ExecuteCustomListBody call, and the no-progress break. Extract them into a single DriveLoop helper used by both. No behaviour change. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 85 +++++++++++++------------ 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index ad962b8b8..90784d86c 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -1047,26 +1047,8 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable, s 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, loop.ItemsElementName, ctx, variable, visualizer, localVars, children); - if (!progress && !ctx.Done) - break; // no items emitted and no break — avoid infinite loop - } + if (ctx.Done) break; + DriveLoop(loop, ctx, variable, visualizer, localVars, children); } } } @@ -2018,6 +2000,45 @@ public CustomListLoopContext(uint startIndex, uint totalSize) } } + /// + /// Drives a single <Loop> element: runs its body once per iteration until a <Break> + /// fires (ctx.Done), the optional while-Condition becomes false, the size limit is reached, + /// a pass makes no progress, or the iteration cap is hit. Shared by the top-level loop and + /// nested <Loop> elements. Returns true if any pass made progress. + /// + private bool DriveLoop( + LoopType loop, + CustomListLoopContext ctx, + IVariableInformation variable, + VisualizerInfo visualizer, + Dictionary localVars, + List children) + { + bool progress = false; + if (loop?.Items == null) + return progress; + + // Cap iterations to cover fast-forwarding through StartIndex items plus one page of + // MAX_EXPAND, with a hard ceiling of 10 000 guarding against malformed natvis where no + // / bounds the loop. + long maxIter = Math.Min((long)ctx.StartIndex + MAX_EXPAND + 1, 10000); + for (long iter = 0; !ctx.Done && ctx.GlobalIndex < ctx.TotalSize && iter < maxIter; iter++) + { + // While-guard: stop if the loop's Condition evaluates to false. + if (!string.IsNullOrEmpty(loop.Condition)) + { + string loopCond = SubstituteLocalVars(loop.Condition, localVars); + if (!EvalCondition(loopCond, variable, visualizer.ScopedNames, visualizer.Intrinsics)) + break; + } + bool passProgress = ExecuteCustomListBody(loop.Items, loop.ItemsElementName, ctx, variable, visualizer, localVars, children); + if (!passProgress && !ctx.Done) + break; // no items emitted and no break — avoid an infinite loop + progress = true; + } + return progress; + } + /// /// 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 @@ -2183,28 +2204,8 @@ ItemsChoiceType ChoiceAt(int i) => } else if (elem is LoopType 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, nestedLoop.ItemsElementName, ctx, variable, visualizer, localVars, children); - if (!innerProgress && !ctx.Done) break; - progress = true; - } - } + // Nested : drive it exactly like the top-level loop. + progress |= DriveLoop(nestedLoop, ctx, variable, visualizer, localVars, children); } else { From 5de0de31221696750398265f07870453893b662c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Wed, 24 Jun 2026 15:32:20 +0200 Subject: [PATCH 07/15] Natvis: fix CustomListItems local variable handling - Sequential init - Pointer-safe normalization Adds unit tests for IsScalarLiteral. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 37 +++++++++++++++---- .../NatvisFormatSpecifierTest.cs | 35 ++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 90784d86c..6f68f2fa7 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -259,6 +259,10 @@ public VisualizerInfo(VisualizerType viz, TypeName name) // 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); + // Matches a plain (optionally signed) integer literal — used to decide whether an + // result is a scalar safe to substitute back in place of its expression. Deliberately does + // NOT match hex (pointers "0x...") or any non-numeric display string. + private static readonly Regex s_integerLiteral = new Regex(@"^\s*-?\d+\s*$", RegexOptions.Compiled); private List _typeVisualizers; private DebuggedProcess _process; private HostConfigurationStore _configStore; @@ -1016,8 +1020,12 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable, s { 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); + // An InitialValue may reference earlier-declared s (per natvis.xsd), + // e.g. . Substitute those first + // (the table is built in document order, so earlier names are already present), + // then resolve field names, template parameters and intrinsics. + string withLocals = SubstituteLocalVars(initVal, localVars); + localVars[v.Name] = ReplaceNamesInExpression(withLocals, variable, visualizer.ScopedNames, visualizer.Intrinsics); } } @@ -2133,16 +2141,18 @@ ItemsChoiceType ChoiceAt(int i) => { // Normalise each updated variable to prevent unbounded growth across // iterations: after each i++ the expression would otherwise grow as - // "(((0)+1)+1)+1...". Evaluate it and store the scalar result so each - // iteration starts from a compact literal. Skip pointer values ("0x...") - // — those must remain as expressions, not substituted as address literals. + // "(((0)+1)+1)+1...". Evaluate it and, ONLY when the result is a plain + // integer literal (the counter case), store that literal so the next + // iteration starts compact. Anything else must keep its expression: + // GetExpressionValue runs FormatDisplayString, so a pointer/struct local + // is rendered as "0x..." or a visualizer display string — storing that + // (a type-less address or non-C++ text) would corrupt later substitutions. try { string normalized = GetExpressionValue(localVars[updatedVar], variable, visualizer.ScopedNames, visualizer.Intrinsics); - if (!string.IsNullOrEmpty(normalized) && - !normalized.TrimStart().StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + if (IsScalarLiteral(normalized)) { - localVars[updatedVar] = normalized; + localVars[updatedVar] = normalized.Trim(); } } catch (Exception e) @@ -2311,6 +2321,17 @@ private static string ApplySingleExec(string execExpr, Dictionary + /// True when is a plain integer literal (optionally signed). Used + /// to decide whether an <Exec> result may be substituted back in place of its + /// expression: pointers ("0x...") and visualizer display strings are not scalar literals and + /// must keep their expression, or later substitutions/evaluations would be corrupted. + /// + internal static bool IsScalarLiteral(string value) + { + return !string.IsNullOrEmpty(value) && s_integerLiteral.IsMatch(value); + } + /// /// Splits an expression on top-level commas — commas not nested inside parentheses /// or square brackets — so a multi-assignment <Exec> like "++idx, ++statptr" is diff --git a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs index 68bdf71fc..5b6b681a5 100644 --- a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs +++ b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs @@ -534,5 +534,40 @@ public void ApplyExecToLocalVars_TrailingComma_NotReportedAsUnhandled() Natvis.ApplyExecToLocalVars("++i,", vars, out var unhandled); Assert.Empty(unhandled); } + + // -- IsScalarLiteral (Exec normalization guard) ----------------------- + + [Fact] + public void IsScalarLiteral_PlainInteger_True() + { + Assert.True(Natvis.IsScalarLiteral("0")); + Assert.True(Natvis.IsScalarLiteral("42")); + Assert.True(Natvis.IsScalarLiteral("-5")); + Assert.True(Natvis.IsScalarLiteral(" 7 ")); + } + + [Fact] + public void IsScalarLiteral_Pointer_False() + { + // Pointers render as hex; must NOT be collapsed into the local-var table. + Assert.False(Natvis.IsScalarLiteral("0x7fff1234")); + Assert.False(Natvis.IsScalarLiteral("0x0")); + } + + [Fact] + public void IsScalarLiteral_DisplayStringOrNonInteger_False() + { + // FormatDisplayString output for visualized values must NOT be collapsed. + Assert.False(Natvis.IsScalarLiteral("{ size=3 }")); + Assert.False(Natvis.IsScalarLiteral("head")); + Assert.False(Natvis.IsScalarLiteral("1.5")); + } + + [Fact] + public void IsScalarLiteral_Empty_False() + { + Assert.False(Natvis.IsScalarLiteral("")); + Assert.False(Natvis.IsScalarLiteral(null)); + } } } From a81b3915b0c5b0c867fd65e3a4436e242cddfbaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Tue, 30 Jun 2026 11:52:46 +0200 Subject: [PATCH 08/15] Natvis: add end-to-end CustomListItems test CppTests integration test exercising the loop engine - CustomListContainer.h: a NULL-terminated singly-linked list - main.cpp: a customList(10, 20, 30) before the return breakpoint - Simple.natvis: a visualizer (Variable/Loop/Break/Item/Exec) - NatvisTests.cs: TestCustomListItems asserts Count=3 and [0..2]=10/20/30, and bumps the breakpoint line constants for the grown main.cpp --- test/CppTests/Tests/NatvisTests.cs | 48 ++++++++++++++++++- .../natvis/src/CustomListContainer.h | 32 +++++++++++++ test/CppTests/debuggees/natvis/src/main.cpp | 6 +++ .../natvis/src/visualizer_files/Simple.natvis | 16 +++++++ 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 test/CppTests/debuggees/natvis/src/CustomListContainer.h diff --git a/test/CppTests/Tests/NatvisTests.cs b/test/CppTests/Tests/NatvisTests.cs index 46f31299c..d2e8f1c4e 100644 --- a/test/CppTests/Tests/NatvisTests.cs +++ b/test/CppTests/Tests/NatvisTests.cs @@ -38,8 +38,8 @@ public NatvisTests(ITestOutputHelper outputHelper) : base(outputHelper) private const string NatvisSourceName = "main.cpp"; // These line numbers will need to change if src/natvis/main.cpp changes - private const int SimpleClassAssignmentLine = 66; - private const int ReturnSourceLine = 90; + private const int SimpleClassAssignmentLine = 67; + private const int ReturnSourceLine = 96; [Theory] [RequiresTestSettings] @@ -720,6 +720,50 @@ public void TestLinkedListItemsCondition(ITestSettings settings) } } + [Theory] + [DependsOnTest(nameof(CompileNatvisDebuggee))] + [RequiresTestSettings] + public void TestCustomListItems(ITestSettings settings) + { + this.TestPurpose("This test checks that a visualizer expands via its // body."); + this.WriteSettings(settings); + + IDebuggee debuggee = Debuggee.Open(this, settings.CompilerSettings, NatvisName, DebuggeeMonikers.Natvis.Default); + + using (IDebuggerRunner runner = CreateDebugAdapterRunner(settings)) + { + this.Comment("Configure launch"); + string visFile = Path.Join(debuggee.SourceRoot, "visualizer_files", "Simple.natvis"); + + LaunchCommand launch = new LaunchCommand(settings.DebuggerSettings, debuggee.OutputPath, visFile, false); + runner.RunCommand(launch); + + this.Comment("Set Breakpoint"); + SourceBreakpoints writerBreakpoints = debuggee.Breakpoints(NatvisSourceName, ReturnSourceLine); + runner.SetBreakpoints(writerBreakpoints); + + runner.Expects.StoppedEvent(StoppedReason.Breakpoint).AfterConfigurationDone(); + + using (IThreadInspector threadInspector = runner.GetThreadInspector()) + { + IFrameInspector currentFrame = threadInspector.Stack.First(); + + this.Comment("Verifying walks the list via its loop body"); + var customList = currentFrame.GetVariable("customList"); + Assert.Equal("3", customList.GetVariable("Count").Value); + + // The node = node->next step must advance every iteration, so all + // three nodes appear as distinct, in-order children [0]..[2]. + Assert.Equal("10", customList.GetVariable("[0]").Value); + Assert.Equal("20", customList.GetVariable("[1]").Value); + Assert.Equal("30", customList.GetVariable("[2]").Value); + } + + runner.Expects.ExitedEvent(exitCode: 0).TerminatedEvent().AfterContinue(); + runner.DisconnectAndVerify(); + } + } + #endregion } } diff --git a/test/CppTests/debuggees/natvis/src/CustomListContainer.h b/test/CppTests/debuggees/natvis/src/CustomListContainer.h new file mode 100644 index 000000000..99e14e4fa --- /dev/null +++ b/test/CppTests/debuggees/natvis/src/CustomListContainer.h @@ -0,0 +1,32 @@ +#include + +// A simple NULL-terminated singly-linked list, visualized via +// (the loop engine: ////). Nodes are appended +// to the tail so that iteration order (head -> next) matches insertion order. +class CustomListContainer +{ +private: + struct Node { + int value; + Node* next; + Node(int v) : value(v), next(NULL) {} + }; + + Node* head; + int count; + +public: + CustomListContainer() : head(NULL), count(0) {} + + void Append(int v) { + Node* n = new Node(v); + if (head == NULL) { + head = n; + } else { + Node* cur = head; + while (cur->next != NULL) cur = cur->next; + cur->next = n; + } + count++; + } +}; diff --git a/test/CppTests/debuggees/natvis/src/main.cpp b/test/CppTests/debuggees/natvis/src/main.cpp index 1216a3e13..b9d8c0b9e 100644 --- a/test/CppTests/debuggees/natvis/src/main.cpp +++ b/test/CppTests/debuggees/natvis/src/main.cpp @@ -7,6 +7,7 @@ #include "SimpleTemplated.h" #include "DataPoint.h" #include "ConditionalLinkedList.h" +#include "CustomListContainer.h" class SimpleDisplayObject { @@ -87,5 +88,10 @@ int main(int argc, char** argv) inactiveList.Add(100); inactiveList.Add(200); + CustomListContainer customList; + customList.Append(10); + customList.Append(20); + customList.Append(30); + return 0; } diff --git a/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis b/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis index 0c833dde5..1fe04558b 100644 --- a/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis +++ b/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis @@ -135,4 +135,20 @@ + + {{ size={count} }} + + count + + + count + + + node->value + node = node->next + + + + + \ No newline at end of file From 27f11f6ff3d1e4023d16803819b9c80281fe2e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Fri, 3 Jul 2026 16:02:30 +0200 Subject: [PATCH 09/15] Natvis: add end-to-end view-filtering test CppTests integration test for IncludeView/ExcludeView and a ",view(name)" specifier invoked from inside the natvis: - main.cpp: a ViewObject{x, y, z} and a ViewHolder wrapping one, before the return breakpoint - Simple.natvis: ViewObject tags a DisplayString and Expand items with IncludeView/ExcludeView; ViewHolder invokes the view via {inner,view(simple)} and inner,view(simple) - NatvisTests.cs: TestViewFiltering asserts the default view (tagged DisplayString and Item skipped) and the simple view through ViewHolder (IncludeView DisplayString selected, view propagated into the expansion, ExcludeView item filtered out) --- test/CppTests/Tests/NatvisTests.cs | 60 ++++++++++++++++++- test/CppTests/debuggees/natvis/src/main.cpp | 23 +++++++ .../natvis/src/visualizer_files/Simple.natvis | 17 ++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/test/CppTests/Tests/NatvisTests.cs b/test/CppTests/Tests/NatvisTests.cs index d2e8f1c4e..5aab319c6 100644 --- a/test/CppTests/Tests/NatvisTests.cs +++ b/test/CppTests/Tests/NatvisTests.cs @@ -38,8 +38,8 @@ public NatvisTests(ITestOutputHelper outputHelper) : base(outputHelper) private const string NatvisSourceName = "main.cpp"; // These line numbers will need to change if src/natvis/main.cpp changes - private const int SimpleClassAssignmentLine = 67; - private const int ReturnSourceLine = 96; + private const int SimpleClassAssignmentLine = 87; + private const int ReturnSourceLine = 119; [Theory] [RequiresTestSettings] @@ -764,6 +764,62 @@ public void TestCustomListItems(ITestSettings settings) } } + [Theory] + [DependsOnTest(nameof(CompileNatvisDebuggee))] + [RequiresTestSettings] + public void TestViewFiltering(ITestSettings settings) + { + this.TestPurpose("This test checks that IncludeView/ExcludeView filter the DisplayString and Expand items, and that a ',view(name)' specifier in a natvis expression propagates into the expansion."); + this.WriteSettings(settings); + + IDebuggee debuggee = Debuggee.Open(this, settings.CompilerSettings, NatvisName, DebuggeeMonikers.Natvis.Default); + + using (IDebuggerRunner runner = CreateDebugAdapterRunner(settings)) + { + this.Comment("Configure launch"); + string visFile = Path.Join(debuggee.SourceRoot, "visualizer_files", "Simple.natvis"); + + LaunchCommand launch = new LaunchCommand(settings.DebuggerSettings, debuggee.OutputPath, visFile, false); + runner.RunCommand(launch); + + this.Comment("Set Breakpoint"); + SourceBreakpoints writerBreakpoints = debuggee.Breakpoints(NatvisSourceName, ReturnSourceLine); + runner.SetBreakpoints(writerBreakpoints); + + runner.Expects.StoppedEvent(StoppedReason.Breakpoint).AfterConfigurationDone(); + + using (IThreadInspector threadInspector = runner.GetThreadInspector()) + { + IFrameInspector currentFrame = threadInspector.Stack.First(); + + this.Comment("Default view: IncludeView=\"simple\" is skipped everywhere."); + // DisplayString: the IncludeView="simple" line is skipped, so the plain one is used. + var vo = currentFrame.GetVariable("vo"); + Assert.Equal("full 1 2 3", vo.Value); + // Expand: X (untagged) and Y (ExcludeView="simple", inactive here) show; Z (IncludeView="simple") is hidden. + Assert.True(vo.Variables.ContainsKey("X"), "X (untagged) should show in the default view"); + Assert.True(vo.Variables.ContainsKey("Y"), "Y (ExcludeView=simple) should show outside the simple view"); + Assert.False(vo.Variables.ContainsKey("Z"), "Z (IncludeView=simple) should be hidden outside the simple view"); + Assert.Equal("1", vo.GetVariable("X").Value); + Assert.Equal("2", vo.GetVariable("Y").Value); + + this.Comment("A DisplayString embedding {inner,view(simple)} renders the inner object's IncludeView=\"simple\" DisplayString."); + var holder = currentFrame.GetVariable("holder"); + Assert.Equal("holder compact 1", holder.Value); + + this.Comment("inner,view(simple) propagates the view: X and Z show, Y (ExcludeView=simple) is filtered out."); + Assert.True(holder.Variables.ContainsKey("X"), "X (untagged) should show in the simple view"); + Assert.True(holder.Variables.ContainsKey("Z"), "Z (IncludeView=simple) should show in the simple view"); + Assert.False(holder.Variables.ContainsKey("Y"), "Y (ExcludeView=simple) should be hidden in the simple view"); + Assert.Equal("1", holder.GetVariable("X").Value); + Assert.Equal("3", holder.GetVariable("Z").Value); + } + + runner.Expects.ExitedEvent(exitCode: 0).TerminatedEvent().AfterContinue(); + runner.DisconnectAndVerify(); + } + } + #endregion } } diff --git a/test/CppTests/debuggees/natvis/src/main.cpp b/test/CppTests/debuggees/natvis/src/main.cpp index b9d8c0b9e..04ffd0015 100644 --- a/test/CppTests/debuggees/natvis/src/main.cpp +++ b/test/CppTests/debuggees/natvis/src/main.cpp @@ -31,6 +31,26 @@ class ShowRawViewObject ShowRawViewObject() : a(30), b(40) {} }; +// Visualized with IncludeView/ExcludeView on both the DisplayString and the Expand items, +// so a ",view(simple)" specifier changes the summary and which rows are shown. +class ViewObject +{ +public: + int x; + int y; + int z; + ViewObject(int xVal, int yVal, int zVal) : x(xVal), y(yVal), z(zVal) {} +}; + +// Invokes the "simple" view from inside the natvis: {inner,view(simple)} in the +// DisplayString and inner,view(simple) in the Expand. +class ViewHolder +{ +public: + ViewObject inner; + ViewHolder() : inner(1, 2, 3) {} +}; + int main(int argc, char** argv) { SimpleDisplayObject obj_1; @@ -93,5 +113,8 @@ int main(int argc, char** argv) customList.Append(20); customList.Append(30); + ViewObject vo(1, 2, 3); + ViewHolder holder; + return 0; } diff --git a/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis b/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis index 1fe04558b..1f3cae92d 100644 --- a/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis +++ b/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis @@ -135,6 +135,23 @@ + + compact {x} + full {x} {y} {z} + + x + y + z + + + + + holder {inner,view(simple)} + + inner,view(simple) + + + {{ size={count} }} From 4e56382b2f057e9e095e6ecd2e5331f860efae1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Fri, 3 Jul 2026 16:34:55 +0200 Subject: [PATCH 10/15] Natvis: support the view() format specifier on watch expressions A watch expression like "obj,view(simple)" previously reached the debugger unchanged and failed to evaluate (lldb: "use of undeclared identifier"). The view() specifier is a natvis concept, so the debugger should never see it: - VariableInformation.ProcessFormatSpecifiers now recognizes a view() specifier, strips it from the evaluated expression, and records the view name. It is handled before the modifier stripping, which could otherwise corrupt a view name containing a modifier letter pair (e.g. "view(second)"). - IVariableInformation exposes the recorded view, and AD7Property passes it to FormatDisplayString and Expand, so the named view selects the DisplayString and filters IncludeView/ExcludeView items in the expansion. The test harness could so far only read the one-line result of a watch expression, not what it expands to: the debugger's reply includes a handle for expanding the result (variablesReference), but the harness discarded it. It now keeps the handle, and the new FrameInspector.EvaluateChildren uses it to return the child rows of an evaluated expression. TestViewFiltering uses both: "vo,view(simple)" must summarize as the IncludeView DisplayString and expand to the view-filtered items. --- src/MIDebugEngine/AD7.Impl/AD7Property.cs | 4 ++-- src/MIDebugEngine/Engine.Impl/Variables.cs | 15 +++++++++++++++ src/MIDebugEngine/Natvis.Impl/Natvis.cs | 1 + test/CppTests/Tests/NatvisTests.cs | 11 +++++++++++ .../Commands/Responses/EvaluateResponseValue.cs | 3 +++ .../OpenDebug/Extensions/FrameInspector.cs | 10 ++++++++++ .../OpenDebug/Extensions/IInspectors.cs | 8 ++++++++ 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/MIDebugEngine/AD7.Impl/AD7Property.cs b/src/MIDebugEngine/AD7.Impl/AD7Property.cs index 422318a3a..0da96f5ab 100644 --- a/src/MIDebugEngine/AD7.Impl/AD7Property.cs +++ b/src/MIDebugEngine/AD7.Impl/AD7Property.cs @@ -72,7 +72,7 @@ public DEBUG_PROPERTY_INFO ConstructDebugPropertyInfo(enum_DEBUGPROP_INFO_FLAGS if ((dwFields & enum_DEBUGPROP_INFO_FLAGS.DEBUGPROP_INFO_VALUE) != 0) { - (propertyInfo.bstrValue, _uiVisualizers) = _engine.DebuggedProcess.Natvis.FormatDisplayString(variable); + (propertyInfo.bstrValue, _uiVisualizers) = _engine.DebuggedProcess.Natvis.FormatDisplayString(variable, variable.NatvisView); propertyInfo.dwFields |= enum_DEBUGPROP_INFO_FLAGS.DEBUGPROP_INFO_VALUE; } @@ -154,7 +154,7 @@ public int EnumChildren(enum_DEBUGPROP_INFO_FLAGS dwFields, uint dwRadix, ref Gu try { _engine.DebuggedProcess.Natvis.WaitDialog.ShowWaitDialog(_variableInformation.Name); - var children = _engine.DebuggedProcess.Natvis.Expand(_variableInformation); + var children = _engine.DebuggedProcess.Natvis.Expand(_variableInformation, _variableInformation.NatvisView); // Count number of children that fit filter (results saved in "fitsFilter") int propertyCount = children.Length; diff --git a/src/MIDebugEngine/Engine.Impl/Variables.cs b/src/MIDebugEngine/Engine.Impl/Variables.cs index 031dd40c8..0b4f1dc81 100644 --- a/src/MIDebugEngine/Engine.Impl/Variables.cs +++ b/src/MIDebugEngine/Engine.Impl/Variables.cs @@ -35,6 +35,7 @@ internal interface IVariableInformation : IDisposable VariableInformation FindChildByName(string name); string EvalDependentExpression(string expr); bool IsVisualized { get; } + string NatvisView { get; } bool IsReadOnly(); bool IsNullPointer(); enum_DEBUGPROP_INFO_FLAGS PropertyInfoFlags { get; set; } @@ -375,6 +376,7 @@ public VariableInformation FindChildByName(string name) private DeferedFormatExpression _deferedFormatExpression; private IVariableInformation _parent; private string _format; + private string _natvisView; // view name from a "view(name)" format specifier private string _strippedName; // "Name" stripped of format specifiers private string _fullname; @@ -392,6 +394,8 @@ public enum NodeType public NodeType VariableNodeType { get; private set; } + public string NatvisView { get { return _natvisView; } } + private static readonly string[] s_stringTypes = new string[] { @"^char *\*$", @"^char *\[[0-9]*\]$", @@ -417,6 +421,17 @@ private string ProcessFormatSpecifiers(string exp, out string formatSpecifier) // Find the format specifier expression string expFS = exp.Substring(lastComma + 1).Trim(); + // A view() specifier (e.g. "obj,view(simple)") selects a named natvis view; it is + // consumed by natvis formatting/expansion rather than the debugger. Handle it before + // the modifier stripping below, which could corrupt a view name that contains one of + // the modifier letter pairs (e.g. "view(second)"). + string viewName = Natvis.Natvis.ExtractViewName(expFS); + if (viewName != null) + { + _natvisView = viewName; + return exp.Substring(0, lastComma); + } + // Strip off modifiers that may be included together with another format specifier, e.g. 'nvoXb' is a valid format specifier, but we only care about the 'Xb' part // This is not quite the right fix -- really the below switch statement should be a series of if statements. But since none of the supported format specifiers // contain any of these characters we can fix this the simple way and remove them. diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 6f68f2fa7..010239a64 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -43,6 +43,7 @@ public SimpleWrapper(string name, AD7Engine engine, IVariableInformation underly public bool IsStringType { get { return Parent.IsStringType; } } public ThreadContext ThreadContext { get { return Parent.ThreadContext; } } public virtual bool IsVisualized { get { return Parent.IsVisualized; } } + public string NatvisView { get { return Parent.NatvisView; } } public virtual enum_DEBUGPROP_INFO_FLAGS PropertyInfoFlags { get; set; } public virtual bool IsReadOnly() => Parent.IsReadOnly(); public bool IsNullPointer() => Parent.IsNullPointer(); diff --git a/test/CppTests/Tests/NatvisTests.cs b/test/CppTests/Tests/NatvisTests.cs index 5aab319c6..e9a840bee 100644 --- a/test/CppTests/Tests/NatvisTests.cs +++ b/test/CppTests/Tests/NatvisTests.cs @@ -813,6 +813,17 @@ public void TestViewFiltering(ITestSettings settings) Assert.False(holder.Variables.ContainsKey("Y"), "Y (ExcludeView=simple) should be hidden in the simple view"); Assert.Equal("1", holder.GetVariable("X").Value); Assert.Equal("3", holder.GetVariable("Z").Value); + + this.Comment("A ',view(simple)' specifier typed on a watch expression selects the IncludeView DisplayString."); + Assert.Equal("compact 1", currentFrame.Evaluate("vo,view(simple)", EvaluateContext.Watch)); + + this.Comment("Watch ',view(simple)' expansion: X and Z show, Y (ExcludeView=simple) is filtered out."); + IDictionary watchItems = currentFrame.EvaluateChildren("vo,view(simple)", EvaluateContext.Watch); + Assert.True(watchItems.ContainsKey("X"), "X (untagged) should show in the simple view"); + Assert.True(watchItems.ContainsKey("Z"), "Z (IncludeView=simple) should show in the simple view"); + Assert.False(watchItems.ContainsKey("Y"), "Y (ExcludeView=simple) should be hidden in the simple view"); + Assert.Equal("1", watchItems["X"].Value); + Assert.Equal("3", watchItems["Z"].Value); } runner.Expects.ExitedEvent(exitCode: 0).TerminatedEvent().AfterContinue(); diff --git a/test/DebuggerTesting/OpenDebug/Commands/Responses/EvaluateResponseValue.cs b/test/DebuggerTesting/OpenDebug/Commands/Responses/EvaluateResponseValue.cs index 27e8e9295..ec9dee6d7 100644 --- a/test/DebuggerTesting/OpenDebug/Commands/Responses/EvaluateResponseValue.cs +++ b/test/DebuggerTesting/OpenDebug/Commands/Responses/EvaluateResponseValue.cs @@ -11,6 +11,9 @@ public sealed class Body { [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public string result; + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public int? variablesReference; } public Body body = new Body(); diff --git a/test/DebuggerTesting/OpenDebug/Extensions/FrameInspector.cs b/test/DebuggerTesting/OpenDebug/Extensions/FrameInspector.cs index ce1e5b06b..7ab55feee 100644 --- a/test/DebuggerTesting/OpenDebug/Extensions/FrameInspector.cs +++ b/test/DebuggerTesting/OpenDebug/Extensions/FrameInspector.cs @@ -169,6 +169,16 @@ public string Evaluate(string expression, EvaluateContext context = EvaluateCont return response.body.result; } + public IDictionary EvaluateChildren(string expression, EvaluateContext context = EvaluateContext.None) + { + this.VerifyNotDisposed(); + EvaluateResponseValue response = this.DebuggerRunner.RunCommand(new EvaluateCommand(expression, this.Id, context)); + int? childReference = response.body.variablesReference; + if (childReference == null || childReference == 0) + return new Dictionary(); + return VariableInspector.GetChildVariables(this.DebuggerRunner, childReference.Value); + } + public string GetSourceContent() { this.VerifyNotDisposed(); diff --git a/test/DebuggerTesting/OpenDebug/Extensions/IInspectors.cs b/test/DebuggerTesting/OpenDebug/Extensions/IInspectors.cs index caf6b2aff..37161ea82 100644 --- a/test/DebuggerTesting/OpenDebug/Extensions/IInspectors.cs +++ b/test/DebuggerTesting/OpenDebug/Extensions/IInspectors.cs @@ -77,6 +77,14 @@ public interface IFrameInspector : IVariableExpander, IInspector /// string Evaluate(string expression, EvaluateContext context = EvaluateContext.None); + /// + /// Evaluates an expression on this frame and returns the child variables of the result + /// (the rows shown when the result is expanded), keyed by name; empty when the result has + /// no children. Unlike , this follows the result's variablesReference, + /// so it can inspect a view-filtered expansion such as "expr,view(name)". + /// + IDictionary EvaluateChildren(string expression, EvaluateContext context = EvaluateContext.None); + /// /// Gets the source associated with this frame /// From 21bea029a45cccb080f3af024b71bc6550450663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Tue, 7 Jul 2026 10:57:37 +0200 Subject: [PATCH 11/15] Natvis: stop the CustomListItems loop when an segment cannot be applied An unapplied segment means the loop state did not advance the way the visualizer intended; continuing emits wrong items or runs to the iteration cap. Previously the segment was logged and the loop kept going. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 010239a64..a43af2460 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -2131,12 +2131,18 @@ ItemsChoiceType ChoiceAt(int i) => // name(s) and substitutes local vars on the right-hand side itself. A single // may update several variables, comma-separated (e.g. "++idx, ++statptr"). var updatedVars = ApplyExecToLocalVars(exec.Value?.Trim() ?? "", localVars, out List unhandledExec); - foreach (string seg in unhandledExec) + if (unhandledExec.Count > 0) { // A segment we could not apply (unsupported form, or an undeclared - // left-hand side) would otherwise silently do nothing; log it so the - // omission is visible rather than producing a wrong/stuck traversal. - _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, "CustomListItems segment not applied (unsupported expression or undeclared variable): " + seg); + // left-hand side) means the loop state did not advance the way the + // visualizer intended; continuing would emit wrong items or run to + // the iteration cap. Log the segments and stop the loop. + foreach (string seg in unhandledExec) + { + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, "CustomListItems segment not applied (unsupported expression or undeclared variable); stopping loop: " + seg); + } + ctx.Done = true; + break; } foreach (string updatedVar in updatedVars) { From 1a1fffab5d988321994916b0da6a08c029a21adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Tue, 7 Jul 2026 12:02:24 +0200 Subject: [PATCH 12/15] Natvis: store CustomListItems loop variables compactly to keep large lists evaluable Each step grew the updated variable's stored expression by one level ("((node)->next)->next..."), so evaluating the nth element cost O(n) and walking a list O(n^2). Past roughly 250 levels the expression exceeded lldb's limits: expanding a 500-element list hung partway through and never completed. The variable is now evaluated raw (no natvis formatting) after each and stored back as a constant-size expression when possible: - integers and bools as literals - pointers as the address cast to their type ("(MyStruct *)0x1234"), the same simplification VariableInformation.FullName applies to long parent expressions; the address is stable while the process is stopped - anything else keeps its expression; after a few growing steps an error row is emitted and the loop stops rather than degrade item by item Adds MakeCompactLiteral unit tests, a 500-element list to the natvis debuggee, and paging assertions to TestCustomListItems. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 122 ++++++++++++++++-- .../NatvisFormatSpecifierTest.cs | 47 +++++++ test/CppTests/Tests/NatvisTests.cs | 11 +- test/CppTests/debuggees/natvis/src/main.cpp | 6 + 4 files changed, 174 insertions(+), 12 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index a43af2460..688f01e59 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -74,6 +74,56 @@ public uint Size() } } + /// + /// A synthetic, non-evaluatable child row that displays a fixed diagnostic message as an + /// error value, e.g. when a CustomListItems loop cannot continue. + /// + internal class MessageVariableInformation : IVariableInformation + { + public MessageVariableInformation(string name, string message, AD7Engine engine, IVariableInformation parent) + { + Name = name; + Value = message; + _engine = engine; + _parent = parent; + } + + private readonly AD7Engine _engine; + private readonly IVariableInformation _parent; + + public string Name { get; } + public string Value { get; } + public string TypeName { get { return null; } } + public bool IsParameter { get { return false; } } + public VariableInformation[] Children { get { return null; } } + public AD7Thread Client { get { return _parent.Client; } } + public bool Error { get { return true; } } + public uint CountChildren { get { return 0; } } + public bool IsChild { get; set; } + public enum_DBG_ATTRIB_FLAGS Access { get { return enum_DBG_ATTRIB_FLAGS.DBG_ATTRIB_ACCESS_NONE; } } + public bool IsStringType { get { return false; } } + public ThreadContext ThreadContext { get { return _parent.ThreadContext; } } + public bool IsVisualized { get { return false; } } + public string NatvisView { get { return null; } } + public enum_DEBUGPROP_INFO_FLAGS PropertyInfoFlags { get; set; } + public bool IsPreformatted { get { return true; } set { } } + public string FullName() { return null; } + public void EnsureChildren() { } + public void AsyncEval(IDebugEventCallback2 pExprCallback) { } + public void AsyncError(IDebugEventCallback2 pExprCallback, IDebugProperty2 error) + { + VariableInformation.AsyncErrorImpl(pExprCallback != null ? new EngineCallback(_engine, pExprCallback) : _engine.Callback, this, error); + } + public void SyncEval(enum_EVALFLAGS dwFlags, DAPEvalFlags dwDAPFlags) { } + public VariableInformation FindChildByName(string name) { return null; } + public string EvalDependentExpression(string expr) { return null; } + public bool IsReadOnly() { return true; } + public bool IsNullPointer() { return false; } + public string Address() { return null; } + public uint Size() { return 0; } + public void Dispose() { } + } + internal class VisualizerWrapper : SimpleWrapper { public readonly Natvis.VisualizerInfo Visualizer; @@ -264,6 +314,9 @@ public VisualizerInfo(VisualizerType viz, TypeName name) // result is a scalar safe to substitute back in place of its expression. Deliberately does // NOT match hex (pointers "0x...") or any non-numeric display string. private static readonly Regex s_integerLiteral = new Regex(@"^\s*-?\d+\s*$", RegexOptions.Compiled); + // Matches the leading hex address of a raw pointer value. GDB may append a symbol or + // string ("0x5555 ", "0x5555 \"text\""), so only the leading token is captured. + private static readonly Regex s_leadingHexAddress = new Regex(@"^\s*(0x[0-9a-fA-F]+)\b", RegexOptions.Compiled); private List _typeVisualizers; private DebuggedProcess _process; private HostConfigurationStore _configStore; @@ -2001,6 +2054,9 @@ private sealed class CustomListLoopContext public readonly uint TotalSize; /// Set to true when a <Break> fires or the page limit is reached. public bool Done; + /// <Exec> steps whose updated variable could not be stored compactly + /// (not an integer, bool or pointer), so its expression grew by one step. + public int NonCompactSteps; public CustomListLoopContext(uint startIndex, uint totalSize) { @@ -2009,6 +2065,14 @@ public CustomListLoopContext(uint startIndex, uint totalSize) } } + /// + /// How many expression-growing <Exec> steps are tolerated before the loop stops with + /// an error row. Kept small: each such step nests the variable's expression one level + /// deeper, so evaluation cost grows quadratically and eventually exceeds the debugger's + /// expression limits (observed as a hang around 250 nested levels with lldb). + /// + private const int MaxNonCompactExecSteps = 10; + /// /// Drives a single <Loop> element: runs its body once per iteration until a <Break> /// fires (ctx.Done), the optional while-Condition becomes false, the size limit is reached, @@ -2146,20 +2210,31 @@ ItemsChoiceType ChoiceAt(int i) => } foreach (string updatedVar in updatedVars) { - // Normalise each updated variable to prevent unbounded growth across - // iterations: after each i++ the expression would otherwise grow as - // "(((0)+1)+1)+1...". Evaluate it and, ONLY when the result is a plain - // integer literal (the counter case), store that literal so the next - // iteration starts compact. Anything else must keep its expression: - // GetExpressionValue runs FormatDisplayString, so a pointer/struct local - // is rendered as "0x..." or a visualizer display string — storing that - // (a type-less address or non-C++ text) would corrupt later substitutions. + // Keep each updated variable's stored expression compact, or it grows by + // one step every iteration ("(((0)+1)+1)+1...", "((node)->next)->next...") + // making evaluation quadratic and, past a few hundred steps, exceeding the + // debugger's expression limits. The variable is evaluated raw (no natvis + // formatting) and stored back as a literal when possible: integers/bools + // as-is, pointers as the address cast to their type ("(MyStruct *)0x1234", + // stable while the process is stopped). Other types (e.g. a by-value + // iterator) cannot be stored back as C++ text; a few such steps are + // tolerated, then an error row is emitted and the loop stops rather than + // degrade item by item. try { - string normalized = GetExpressionValue(localVars[updatedVar], variable, visualizer.ScopedNames, visualizer.Intrinsics); - if (IsScalarLiteral(normalized)) + IVariableInformation updated = GetExpression(localVars[updatedVar], variable, visualizer.ScopedNames, null, visualizer.Intrinsics); + string compact = updated.Error ? null : MakeCompactLiteral(updated.Value, updated.TypeName); + if (compact != null) { - localVars[updatedVar] = normalized.Trim(); + localVars[updatedVar] = compact; + } + else if (++ctx.NonCompactSteps > MaxNonCompactExecSteps) + { + string message = "CustomListItems: the value of loop variable '" + updatedVar + "' is not an integer, bool or pointer; the loop cannot continue efficiently and was stopped."; + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Warning, message); + children.Add(new MessageVariableInformation("[Error]", message, _process.Engine, variable)); + ctx.Done = true; + break; } } catch (Exception e) @@ -2339,6 +2414,31 @@ internal static bool IsScalarLiteral(string value) return !string.IsNullOrEmpty(value) && s_integerLiteral.IsMatch(value); } + /// + /// Returns a compact C++ expression equivalent to a loop variable's current value, or null + /// when none exists. Integers and bools are returned as literals; pointers are returned as + /// the address cast to their type ("(MyStruct *)0x1234", the same simplification + /// applies to long parent expressions). Keeping + /// the stored expression constant-size is what stops it growing by one step per iteration + /// ("((node)->next)->next...") until evaluation becomes quadratic and finally exceeds the + /// debugger's expression limits. + /// + internal static string MakeCompactLiteral(string rawValue, string typeName) + { + if (string.IsNullOrEmpty(rawValue)) + return null; + string value = rawValue.Trim(); + if (s_integerLiteral.IsMatch(value) || value == "true" || value == "false") + return value; + if (!string.IsNullOrEmpty(typeName) && typeName.TrimEnd().EndsWith("*", StringComparison.Ordinal)) + { + Match m = s_leadingHexAddress.Match(value); + if (m.Success) + return "(" + typeName + ")" + m.Groups[1].Value; + } + return null; + } + /// /// Splits an expression on top-level commas — commas not nested inside parentheses /// or square brackets — so a multi-assignment <Exec> like "++idx, ++statptr" is diff --git a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs index 5b6b681a5..ad3ca0b37 100644 --- a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs +++ b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs @@ -569,5 +569,52 @@ public void IsScalarLiteral_Empty_False() Assert.False(Natvis.IsScalarLiteral("")); Assert.False(Natvis.IsScalarLiteral(null)); } + + // -- MakeCompactLiteral (Exec normalization) -------------------------- + + [Fact] + public void MakeCompactLiteral_Integer_ReturnsLiteral() + { + Assert.Equal("42", Natvis.MakeCompactLiteral("42", "int")); + Assert.Equal("-5", Natvis.MakeCompactLiteral(" -5 ", "long")); + } + + [Fact] + public void MakeCompactLiteral_Bool_ReturnsLiteral() + { + Assert.Equal("true", Natvis.MakeCompactLiteral("true", "bool")); + Assert.Equal("false", Natvis.MakeCompactLiteral("false", "bool")); + } + + [Fact] + public void MakeCompactLiteral_Pointer_ReturnsCastAddress() + { + Assert.Equal("(MyStruct *)0x1234", Natvis.MakeCompactLiteral("0x1234", "MyStruct *")); + Assert.Equal("(Container::Node *)0x0", Natvis.MakeCompactLiteral("0x0", "Container::Node *")); + } + + [Fact] + public void MakeCompactLiteral_PointerWithGdbAnnotation_UsesLeadingAddressOnly() + { + // GDB appends symbol or string text after the address. + Assert.Equal("(char *)0x5555", Natvis.MakeCompactLiteral("0x5555 \"hello\"", "char *")); + Assert.Equal("(Node *)0x7fff12", Natvis.MakeCompactLiteral("0x7fff12 ", "Node *")); + } + + [Fact] + public void MakeCompactLiteral_HexValueOfNonPointerType_Null() + { + // A hex-looking value may not be stored back unless the type really is a pointer. + Assert.Null(Natvis.MakeCompactLiteral("0x1234", "unsigned int")); + } + + [Fact] + public void MakeCompactLiteral_NonCompactableValues_Null() + { + Assert.Null(Natvis.MakeCompactLiteral("{ size=3 }", "Container")); + Assert.Null(Natvis.MakeCompactLiteral("1.5", "double")); + Assert.Null(Natvis.MakeCompactLiteral("", "int")); + Assert.Null(Natvis.MakeCompactLiteral(null, "int")); + } } } diff --git a/test/CppTests/Tests/NatvisTests.cs b/test/CppTests/Tests/NatvisTests.cs index e9a840bee..8c9fbefb2 100644 --- a/test/CppTests/Tests/NatvisTests.cs +++ b/test/CppTests/Tests/NatvisTests.cs @@ -39,7 +39,7 @@ public NatvisTests(ITestOutputHelper outputHelper) : base(outputHelper) // These line numbers will need to change if src/natvis/main.cpp changes private const int SimpleClassAssignmentLine = 87; - private const int ReturnSourceLine = 119; + private const int ReturnSourceLine = 125; [Theory] [RequiresTestSettings] @@ -757,6 +757,15 @@ public void TestCustomListItems(ITestSettings settings) Assert.Equal("10", customList.GetVariable("[0]").Value); Assert.Equal("20", customList.GetVariable("[1]").Value); Assert.Equal("30", customList.GetVariable("[2]").Value); + + this.Comment("Verifying a 500-element list pages correctly (pointer loop variables are stored compactly, so the walk stays evaluable)"); + var customList500 = currentFrame.GetVariable("customList500"); + Assert.Equal("500", customList500.GetVariable("Count").Value); + Assert.Equal("0", customList500.GetVariable("[0]").Value); + Assert.Equal("49", customList500.GetVariable("[49]").Value); + var more = customList500.GetVariable("[More...]"); + Assert.Equal("50", more.GetVariable("[50]").Value); + Assert.Equal("99", more.GetVariable("[99]").Value); } runner.Expects.ExitedEvent(exitCode: 0).TerminatedEvent().AfterContinue(); diff --git a/test/CppTests/debuggees/natvis/src/main.cpp b/test/CppTests/debuggees/natvis/src/main.cpp index 04ffd0015..34bf5ff37 100644 --- a/test/CppTests/debuggees/natvis/src/main.cpp +++ b/test/CppTests/debuggees/natvis/src/main.cpp @@ -113,6 +113,12 @@ int main(int argc, char** argv) customList.Append(20); customList.Append(30); + CustomListContainer customList500; + for (int i = 0; i < 500; i++) + { + customList500.Append(i); + } + ViewObject vo(1, 2, 3); ViewHolder holder; From 972b00ca6d36e7c6d14156424258f3c9d2cb4f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Tue, 7 Jul 2026 13:23:04 +0200 Subject: [PATCH 13/15] Natvis: resume CustomListItems pagination from saved loop state Expanding a "[More...]" node re-ran the loop from the start and skipped the already-shown items, so each page re-walked everything before it. The page is now cut at a pass boundary and the "[More...]" node saves the loop state there (the local-variable table, compact after normalization, and the loop position); expanding it resumes the walk in place, like the LinkedListItems and TreeItems continuations. A pass always runs to completion so none of its s are lost, which can let a page slightly exceed the page size. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 77 +++++++++++++++++++------ 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 688f01e59..89dee80b8 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -168,6 +168,24 @@ public LinkedListContinueWrapper(string name, AD7Engine engine, IVariableInforma } } + /// + /// Represents the continuation of a CustomListItemsType: the saved natvis local-variable + /// table and loop position let the "[More...]" expansion resume where the previous page + /// stopped instead of re-walking the list from the start. StartIndex (from the base class) + /// is the global item index to continue numbering from. + /// + internal sealed class CustomListContinueWrapper : PaginatedVisualizerWrapper + { + public readonly Dictionary SavedLocalVars; + public readonly int LoopIndex; + public CustomListContinueWrapper(string name, AD7Engine engine, IVariableInformation underlyingVariable, Natvis.VisualizerInfo viz, bool isVisualizerView, Dictionary savedLocalVars, int loopIndex, uint startIndex) + : base(name, engine, underlyingVariable, viz, isVisualizerView, startIndex) + { + SavedLocalVars = savedLocalVars; + LoopIndex = loopIndex; + } + } + internal class Node { public enum ScanState @@ -1067,8 +1085,16 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable, s // 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. + // A "[More...]" continuation instead restores the table saved when the previous page + // filled, so the walk resumes where it stopped rather than re-running from the start. var localVars = new Dictionary(StringComparer.Ordinal); - if (customList.Items != null) + var continueCLI = variable as CustomListContinueWrapper; + if (continueCLI != null) + { + foreach (var savedVar in continueCLI.SavedLocalVars) + localVars[savedVar.Key] = savedVar.Value; + } + else if (customList.Items != null) { foreach (var v in customList.Items) { @@ -1106,11 +1132,13 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable, s startIndex = pvwCLI.StartIndex; var ctx = new CustomListLoopContext(startIndex, totalSize); + if (continueCLI != null) + ctx.GlobalIndex = startIndex; // resume numbering where the previous page stopped - foreach (var loop in customList.Loop) + for (int loopIndex = continueCLI?.LoopIndex ?? 0; loopIndex < customList.Loop.Length; loopIndex++) { if (ctx.Done) break; - DriveLoop(loop, ctx, variable, visualizer, localVars, children); + DriveLoop(customList.Loop[loopIndex], ctx, variable, visualizer, localVars, children, loopIndex); } } } @@ -2085,16 +2113,18 @@ private bool DriveLoop( IVariableInformation variable, VisualizerInfo visualizer, Dictionary localVars, - List children) + List children, + int topLevelLoopIndex = -1) { bool progress = false; if (loop?.Items == null) return progress; - // Cap iterations to cover fast-forwarding through StartIndex items plus one page of - // MAX_EXPAND, with a hard ceiling of 10 000 guarding against malformed natvis where no - // / bounds the loop. - long maxIter = Math.Min((long)ctx.StartIndex + MAX_EXPAND + 1, 10000); + // Cap iterations to cover fast-forwarding through any remaining StartIndex items plus + // one page of MAX_EXPAND, with a hard ceiling of 10 000 guarding against malformed + // natvis where no / bounds the loop. + long remaining = ctx.StartIndex > ctx.GlobalIndex ? ctx.StartIndex - ctx.GlobalIndex : 0; + long maxIter = Math.Min(remaining + MAX_EXPAND + 1, 10000); for (long iter = 0; !ctx.Done && ctx.GlobalIndex < ctx.TotalSize && iter < maxIter; iter++) { // While-guard: stop if the loop's Condition evaluates to false. @@ -2104,6 +2134,22 @@ private bool DriveLoop( if (!EvalCondition(loopCond, variable, visualizer.ScopedNames, visualizer.Intrinsics)) break; } + // A full page is cut here, between passes, so the loop state is consistent (the + // pass that filled the page has run to completion, including its trailing + // steps). The continuation saves the local-variable table — compact literals + // after normalization — and the position, letting the "[More...]" node + // resume the walk here instead of re-running it from the start. Only the + // top-level loop takes snapshots; a nested runs within its pass. + if (topLevelLoopIndex >= 0 && ctx.Emitted >= MAX_EXPAND) + { + children.Add(new CustomListContinueWrapper( + ResourceStrings.MoreView, _process.Engine, variable, visualizer, + isVisualizerView: true, + new Dictionary(localVars, StringComparer.Ordinal), + topLevelLoopIndex, ctx.GlobalIndex)); + ctx.Done = true; + break; + } bool passProgress = ExecuteCustomListBody(loop.Items, loop.ItemsElementName, ctx, variable, visualizer, localVars, children); if (!passProgress && !ctx.Done) break; // no items emitted and no break — avoid an infinite loop @@ -2159,7 +2205,11 @@ ItemsChoiceType ChoiceAt(int i) => continue; } - if (ctx.GlobalIndex >= ctx.StartIndex && ctx.Emitted < MAX_EXPAND) + // A pass always runs to completion, even when it fills the page mid-body: + // stopping between two s of the same pass would lose the later ones, + // since the pagination continuation resumes at a pass boundary. A page can + // therefore slightly exceed MAX_EXPAND when a pass emits several items. + if (ctx.GlobalIndex >= ctx.StartIndex) { string rawExpr = SubstituteLocalVars(li.Value?.Trim() ?? "", localVars); string processedExpr = ReplaceNamesInExpression(rawExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics); @@ -2171,15 +2221,6 @@ ItemsChoiceType ChoiceAt(int i) => } 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 ExecType exec) { From 7bba548d54c2940f1221c5db4e130875cf6ce7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Fri, 10 Jul 2026 12:51:38 +0200 Subject: [PATCH 14/15] Natvis: fall back to Break-driven termination when cannot be parsed ParseUint(throwOnError: false) returns zero on unparsable input without throwing, so the intended fallback in the catch block (totalSize stays MaxValue and terminates the loop) never applied: an unparsable produced an empty expansion instead. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 89dee80b8..f64e08cd1 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -1120,7 +1120,7 @@ private IVariableInformation[] ExpandVisualized(IVariableInformation variable, s { string szExpr = SubstituteLocalVars(sz.Value?.Trim() ?? "0", localVars); string szVal = GetExpressionValue(szExpr, variable, visualizer.ScopedNames, visualizer.Intrinsics); - totalSize = MICore.Debugger.ParseUint(szVal, throwOnError: false); + totalSize = MICore.Debugger.ParseUint(szVal, throwOnError: true); } catch (Exception) { /* leave totalSize as MaxValue so Break drives termination */ } break; From 8abc0a0edf6b38adb6394d4459e9ec8afbfe3b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Fri, 10 Jul 2026 15:17:47 +0200 Subject: [PATCH 15/15] Natvis: compact integer loop variables when the engine radix is 16 With the engine radix set to 16, integers evaluate to hex text ("0x1f"), which the decimal-only literal check rejected: integer loop variables stopped compacting and the loop ended with the error row. Hex integers are now stored back with their type cast ("(int)0xffffffd6"), which a bare hex literal would not survive: it is unsigned, so a negative value would flip sign in later comparisons. Pointers are recognized by type first, since a pointer must keep its type cast either way. Removes IsScalarLiteral, no longer used outside its unit tests. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 46 +++++++------- .../NatvisFormatSpecifierTest.cs | 62 +++++++------------ 2 files changed, 46 insertions(+), 62 deletions(-) diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index f64e08cd1..145ddcedb 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -328,10 +328,10 @@ public VisualizerInfo(VisualizerType viz, TypeName name) // 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); - // Matches a plain (optionally signed) integer literal — used to decide whether an - // result is a scalar safe to substitute back in place of its expression. Deliberately does - // NOT match hex (pointers "0x...") or any non-numeric display string. + // Matches a plain (optionally signed) decimal integer literal. private static readonly Regex s_integerLiteral = new Regex(@"^\s*-?\d+\s*$", RegexOptions.Compiled); + // Matches a hex integer literal — how integers render when the engine radix is 16. + private static readonly Regex s_hexIntegerLiteral = new Regex(@"^0x[0-9a-fA-F]+$", RegexOptions.Compiled); // Matches the leading hex address of a raw pointer value. GDB may append a symbol or // string ("0x5555 ", "0x5555 \"text\""), so only the leading token is captured. private static readonly Regex s_leadingHexAddress = new Regex(@"^\s*(0x[0-9a-fA-F]+)\b", RegexOptions.Compiled); @@ -2444,39 +2444,37 @@ private static string ApplySingleExec(string execExpr, Dictionary - /// True when is a plain integer literal (optionally signed). Used - /// to decide whether an <Exec> result may be substituted back in place of its - /// expression: pointers ("0x...") and visualizer display strings are not scalar literals and - /// must keep their expression, or later substitutions/evaluations would be corrupted. - /// - internal static bool IsScalarLiteral(string value) - { - return !string.IsNullOrEmpty(value) && s_integerLiteral.IsMatch(value); - } - /// /// Returns a compact C++ expression equivalent to a loop variable's current value, or null - /// when none exists. Integers and bools are returned as literals; pointers are returned as - /// the address cast to their type ("(MyStruct *)0x1234", the same simplification - /// applies to long parent expressions). Keeping - /// the stored expression constant-size is what stops it growing by one step per iteration - /// ("((node)->next)->next...") until evaluation becomes quadratic and finally exceeds the - /// debugger's expression limits. + /// when none exists. Pointers are returned as the address cast to their type + /// ("(MyStruct *)0x1234", the same simplification + /// applies to long parent expressions); + /// integers (decimal or hex, depending on the engine radix) and bools are returned as + /// literals. Keeping the stored expression constant-size is what stops it growing by one + /// step per iteration ("((node)->next)->next...") until evaluation becomes quadratic and + /// finally exceeds the debugger's expression limits. /// internal static string MakeCompactLiteral(string rawValue, string typeName) { if (string.IsNullOrEmpty(rawValue)) return null; string value = rawValue.Trim(); - if (s_integerLiteral.IsMatch(value) || value == "true" || value == "false") - return value; + // Pointers are recognized by type, not value shape: a pointer must be stored with its + // type cast (a bare number would break later "->" accesses), so a pointer whose value + // has no leading address is not compactable at all. if (!string.IsNullOrEmpty(typeName) && typeName.TrimEnd().EndsWith("*", StringComparison.Ordinal)) { Match m = s_leadingHexAddress.Match(value); - if (m.Success) - return "(" + typeName + ")" + m.Groups[1].Value; + return m.Success ? "(" + typeName + ")" + m.Groups[1].Value : null; } + if (s_integerLiteral.IsMatch(value) || value == "true" || value == "false") + return value; + // A hex integer (how integers render when the engine radix is 16) is stored with its + // type cast: a bare hex literal is unsigned, so "(int)0xffffffd6" is needed to keep a + // negative value negative in later comparisons. Without a known type it cannot be + // stored back safely. + if (s_hexIntegerLiteral.IsMatch(value) && !string.IsNullOrEmpty(typeName)) + return "(" + typeName + ")" + value; return null; } diff --git a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs index ad3ca0b37..f603635bd 100644 --- a/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs +++ b/src/MIDebugEngineUnitTests/NatvisFormatSpecifierTest.cs @@ -535,41 +535,6 @@ public void ApplyExecToLocalVars_TrailingComma_NotReportedAsUnhandled() Assert.Empty(unhandled); } - // -- IsScalarLiteral (Exec normalization guard) ----------------------- - - [Fact] - public void IsScalarLiteral_PlainInteger_True() - { - Assert.True(Natvis.IsScalarLiteral("0")); - Assert.True(Natvis.IsScalarLiteral("42")); - Assert.True(Natvis.IsScalarLiteral("-5")); - Assert.True(Natvis.IsScalarLiteral(" 7 ")); - } - - [Fact] - public void IsScalarLiteral_Pointer_False() - { - // Pointers render as hex; must NOT be collapsed into the local-var table. - Assert.False(Natvis.IsScalarLiteral("0x7fff1234")); - Assert.False(Natvis.IsScalarLiteral("0x0")); - } - - [Fact] - public void IsScalarLiteral_DisplayStringOrNonInteger_False() - { - // FormatDisplayString output for visualized values must NOT be collapsed. - Assert.False(Natvis.IsScalarLiteral("{ size=3 }")); - Assert.False(Natvis.IsScalarLiteral("head")); - Assert.False(Natvis.IsScalarLiteral("1.5")); - } - - [Fact] - public void IsScalarLiteral_Empty_False() - { - Assert.False(Natvis.IsScalarLiteral("")); - Assert.False(Natvis.IsScalarLiteral(null)); - } - // -- MakeCompactLiteral (Exec normalization) -------------------------- [Fact] @@ -602,10 +567,31 @@ public void MakeCompactLiteral_PointerWithGdbAnnotation_UsesLeadingAddressOnly() } [Fact] - public void MakeCompactLiteral_HexValueOfNonPointerType_Null() + public void MakeCompactLiteral_HexInteger_ReturnsCastLiteral() + { + // With the engine radix set to 16, integers render as hex. The type cast keeps the + // value's signedness: a bare hex literal is unsigned, so "(int)0xffffffd6" is needed + // for -42 to stay negative in later comparisons. + Assert.Equal("(int)0x2a", Natvis.MakeCompactLiteral("0x2a", "int")); + Assert.Equal("(unsigned int)0x1234", Natvis.MakeCompactLiteral("0x1234", "unsigned int")); + Assert.Equal("(int)0xffffffd6", Natvis.MakeCompactLiteral("0xffffffd6", "int")); + } + + [Fact] + public void MakeCompactLiteral_HexIntegerWithoutType_Null() + { + // Without a type the cast cannot be emitted, and a bare hex literal could change + // signedness, so the value is not stored back. + Assert.Null(Natvis.MakeCompactLiteral("0x2a", null)); + Assert.Null(Natvis.MakeCompactLiteral("0x2a", "")); + } + + [Fact] + public void MakeCompactLiteral_PointerWithoutAddress_Null() { - // A hex-looking value may not be stored back unless the type really is a pointer. - Assert.Null(Natvis.MakeCompactLiteral("0x1234", "unsigned int")); + // A pointer needs its type cast, so a value without a leading address (e.g. GDB's + // "") cannot be stored back. + Assert.Null(Natvis.MakeCompactLiteral("", "Node *")); } [Fact]