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 5e7e30d05..145ddcedb 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(); @@ -73,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; @@ -117,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 @@ -249,6 +318,23 @@ 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); + // 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); private List _typeVisualizers; private DebuggedProcess _process; private HostConfigurationStore _configStore; @@ -453,7 +539,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 +565,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 +617,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 +625,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 +682,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 +700,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 +1037,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 +1055,92 @@ 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 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. + // 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); + 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) + { + if (string.IsNullOrEmpty(v.Name)) continue; + string initVal = v.InitialValue ?? "0"; + // 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); + } + } + + // 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: true); + } + 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); + if (continueCLI != null) + ctx.GlobalIndex = startIndex; // resume numbering where the previous page stopped + + for (int loopIndex = continueCLI?.LoopIndex ?? 0; loopIndex < customList.Loop.Length; loopIndex++) + { + if (ctx.Done) break; + DriveLoop(customList.Loop[loopIndex], ctx, variable, visualizer, localVars, children, loopIndex); + } + } } if (!(variable is VisualizerWrapper) && !expandType.HideRawView) // don't stack wrappers, and respect HideRawView { @@ -1122,12 +1307,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 +1486,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 +1710,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 +1939,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 +1992,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) @@ -1771,6 +2065,484 @@ 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; + /// <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) + { + StartIndex = startIndex; + TotalSize = 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, + /// 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, + int topLevelLoopIndex = -1) + { + bool progress = false; + if (loop?.Items == null) + return progress; + + // 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. + if (!string.IsNullOrEmpty(loop.Condition)) + { + string loopCond = SubstituteLocalVars(loop.Condition, localVars); + 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 + 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 + /// no observable effect (used to detect infinite-loop conditions). + /// + private bool ExecuteCustomListBody( + object[] body, + ItemsChoiceType[] choices, + CustomListLoopContext ctx, + IVariableInformation variable, + VisualizerInfo visualizer, + Dictionary localVars, + List children) + { + 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 BreakType 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 CustomListItemType 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; + } + + // 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); + 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; + } + else if (elem is ExecType exec) + { + // 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; + } + // 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); + if (unhandledExec.Count > 0) + { + // A segment we could not apply (unsupported form, or an undeclared + // 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) + { + // 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 + { + 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] = 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) + { + // 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 + } + else if (elem is IfType ifElem) + { + // 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, ifElem.ItemsElementName, ctx, variable, visualizer, localVars, children); + + // 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) + { + 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, elseIfElem.ItemsElementName, ctx, variable, visualizer, localVars, children); + taken = true; + } + } + } + + // Consume an immediately following element. + if (idx + 1 < body.Length && body[idx + 1] is ElseType elseElem) + { + idx++; + if (!taken && elseElem.Items != null) + progress |= ExecuteCustomListBody(elseElem.Items, elseElem.ItemsElementName, ctx, variable, visualizer, localVars, children); + } + } + else if (elem is LoopType nestedLoop) + { + // Nested : drive it exactly like the top-level loop. + progress |= DriveLoop(nestedLoop, ctx, variable, visualizer, localVars, children); + } + 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; + } + + /// + /// 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 List ApplyExecToLocalVars(string execExpr, Dictionary localVars) + => ApplyExecToLocalVars(execExpr, localVars, out _); + + internal static List ApplyExecToLocalVars(string execExpr, Dictionary localVars, out List unhandled) + { + 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); + 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; + } + + /// + /// Returns a compact C++ expression equivalent to a loop variable's current value, or null + /// 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(); + // 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); + 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; + } + + /// + /// 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 + /// 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 c3fdb38e0..fcc69526e 100644 --- a/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs +++ b/src/MIDebugEngine/Natvis.Impl/NatvisXsdTypes.cs @@ -514,19 +514,25 @@ public string Value { public partial class CustomListItemsType { private VariableType[] itemsField; - + private CustomListSizeType[] items1Field; - + private SkipType itemField; - + + private LoopType[] loopField; + private bool optionalField; - + private bool optionalFieldSpecified; - + private string conditionField; - + + private string includeViewField; + + private string excludeViewField; + private uint maxItemsPerViewField; - + private bool maxItemsPerViewFieldSpecified; /// @@ -561,7 +567,18 @@ public SkipType Item { this.itemField = value; } } - + + /// + [System.Xml.Serialization.XmlElementAttribute("Loop")] + public LoopType[] Loop { + get { + return this.loopField; + } + set { + this.loopField = value; + } + } + /// [System.Xml.Serialization.XmlAttributeAttribute()] public bool Optional { @@ -572,7 +589,7 @@ public bool Optional { this.optionalField = value; } } - + /// [System.Xml.Serialization.XmlIgnoreAttribute()] public bool OptionalSpecified { @@ -583,7 +600,7 @@ public bool OptionalSpecified { this.optionalFieldSpecified = value; } } - + /// [System.Xml.Serialization.XmlAttributeAttribute()] public string Condition { @@ -594,7 +611,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 { @@ -618,6 +657,266 @@ 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, 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. + /// 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 CustomListItemType { + 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 BreakType { + 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 ExecType { + 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 IfType { + private string conditionField; + private object[] itemsField; + + private ItemsChoiceType[] itemsElementNameField; + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Condition { + get { return this.conditionField; } + set { this.conditionField = value; } + } + + /// + [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 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 ElseType { + private object[] itemsField; + + private ItemsChoiceType[] itemsElementNameField; + + /// + [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). + /// 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 LoopType { + private string conditionField; + private object[] itemsField; + + private ItemsChoiceType[] itemsElementNameField; + + /// + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Condition { + get { return this.conditionField; } + set { this.conditionField = value; } + } + + /// + [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 --------------------------------- + /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] [System.SerializableAttribute()] @@ -717,13 +1016,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 +1061,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 +1095,7 @@ public string Value { } } } - + /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] [System.SerializableAttribute()] @@ -852,11 +1177,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 +1192,7 @@ public string Condition { this.conditionField = value; } } - + /// [System.Xml.Serialization.XmlTextAttribute()] public string Value { @@ -879,7 +1204,7 @@ public string Value { } } } - + /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] [System.SerializableAttribute()] @@ -887,15 +1212,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 +1270,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 +1304,7 @@ public string Value { } } } - + /// [System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "4.8.3928.0")] [System.SerializableAttribute()] @@ -1263,17 +1614,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 +1685,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..f603635bd 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] @@ -113,5 +235,372 @@ 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)); + } + + // -- 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); + } + + // -- 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_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 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] + 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 46f31299c..8c9fbefb2 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 = 87; + private const int ReturnSourceLine = 125; [Theory] [RequiresTestSettings] @@ -720,6 +720,126 @@ 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); + + 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(); + runner.DisconnectAndVerify(); + } + } + + [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); + + 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(); + 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..34bf5ff37 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 { @@ -30,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; @@ -87,5 +108,19 @@ int main(int argc, char** argv) inactiveList.Add(100); inactiveList.Add(200); + CustomListContainer customList; + customList.Append(10); + 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; + 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..1f3cae92d 100644 --- a/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis +++ b/test/CppTests/debuggees/natvis/src/visualizer_files/Simple.natvis @@ -135,4 +135,37 @@ + + compact {x} + full {x} {y} {z} + + x + y + z + + + + + holder {inner,view(simple)} + + inner,view(simple) + + + + + {{ size={count} }} + + count + + + count + + + node->value + node = node->next + + + + + \ No newline at end of file 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 ///