From 1d92b25256ba7755330a87f8297c5ebf571359de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Mon, 6 Jul 2026 15:25:48 +0200 Subject: [PATCH 1/3] Natvis: resolve typedef and base-class names when rule lookup misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GDB/LLDB report a variable's type under its declared name, so a typedef alias or a subclass of a visualized type misses the wildcard rule of its underlying type and falls back to raw struct display, even though a matching rule exists and the debugger may expose no base-class child for FindType's existing walk. Add a fallback to FindType: on a miss, ask the debugger to resolve the name (gdb: whatis/ptype; lldb: type lookup) and retry with the result — the head type name when the debugger expanded an alias, or the first base class when the reply names the queried type itself. Results are cached per session, so each unmatched name costs at most one round of queries. Extracted names are validated before entering a console command, the cache or the log. Add unit tests for the reply parser and the decoration stripper. --- src/MIDebugEngine/Natvis.Impl/Natvis.cs | 370 ++++++++++++++++++ .../NatvisTypeNameResolutionTest.cs | 244 ++++++++++++ 2 files changed, 614 insertions(+) create mode 100644 src/MIDebugEngineUnitTests/NatvisTypeNameResolutionTest.cs diff --git a/src/MIDebugEngine/Natvis.Impl/Natvis.cs b/src/MIDebugEngine/Natvis.Impl/Natvis.cs index 5e7e30d05..66b275a8a 100755 --- a/src/MIDebugEngine/Natvis.Impl/Natvis.cs +++ b/src/MIDebugEngine/Natvis.Impl/Natvis.cs @@ -13,6 +13,7 @@ using System.Reflection; using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; @@ -249,10 +250,27 @@ 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+"); + // Restricts debugger type-name queries to plain C++ type names (identifiers, + // scope operators, template arguments, cv/ref/pointer decorations) so that no + // other console syntax can be smuggled into the query command. Literal spaces, + // not \s: a newline inside a name could terminate the MI console line early. + private static readonly Regex s_safeTypeNameForQuery = new Regex(@"^[A-Za-z_$][\w$]*(::[\w$]+)*( *<[\w :<>,\*&$]*>)?( *[\*&])*$", RegexOptions.Compiled); private List _typeVisualizers; private DebuggedProcess _process; private HostConfigurationStore _configStore; private Dictionary _vizCache; + // Debugger type-name resolution results (typedef target / first base class), + // keyed by the reported type name. Null values cache negative results so each + // unmatched name costs at most one round of debugger queries per session. + private Dictionary _typeNameQueryCache = new Dictionary(); + // Single-token built-in type names, never worth a debugger query (natvis rules + // cannot target primitives). Multi-word forms ("unsigned long") are already + // rejected by s_safeTypeNameForQuery. + private static readonly HashSet s_primitiveTypeNames = new HashSet(StringComparer.Ordinal) + { + "void", "bool", "char", "short", "int", "long", "float", "double", + "signed", "unsigned", "wchar_t", "char8_t", "char16_t", "char32_t" + }; private uint _depth; public HostWaitDialog WaitDialog { get; private set; } @@ -1257,9 +1275,361 @@ private VisualizerInfo FindType(IVariableInformation variable) } parsedName = TypeName.Parse(var.TypeName, _process.Logger.NatvisLogger); } + + // Name-based fallback: the debugger reports a variable's type under its + // declared name. A typedef alias or a subclass of a visualized type + // therefore misses the wildcard rule of its underlying type, and the + // debugger may expose no BaseClass child for FindBaseClass to walk. + // Ask the debugger to resolve the name (typedef target or first base class) + // and retry the rule lookup with the resolved name. + if (_typeVisualizers.Count == 0) + { + return null; // no natvis loaded — a resolved name could not match anything + } + string currentName = StripTypeDecorations(variable.TypeName); + for (int chain = 0; chain < MAX_ALIAS_CHAIN && !String.IsNullOrEmpty(currentName); chain++) + { + string resolvedName = ResolveTypeNameViaDebugger(currentName); + if (String.IsNullOrEmpty(resolvedName) || resolvedName == currentName) + { + break; + } + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Verbose, "FindType: '{0}' resolved to '{1}'", currentName, resolvedName); + TypeName resolvedParsed = TypeName.Parse(resolvedName, _process.Logger.NatvisLogger); + if (resolvedParsed == null) + { + break; + } + // On success Scan caches the visualizer under variable.TypeName, so the + // next variable reported under this alias hits _vizCache directly. + var visualizer = Scan(resolvedParsed, variable); + if (visualizer != null) + { + return visualizer; + } + currentName = resolvedName; // alias of an alias / base of a base + } + return null; + } + + /// + /// Strips cv-qualifiers and pointer/reference decorations from a reported type + /// name so it can be used in a debugger type query, e.g. + /// "const TextItemList &" -> "TextItemList". + /// + internal static string StripTypeDecorations(string typeName) + { + if (String.IsNullOrEmpty(typeName)) + { + return typeName; + } + string result = typeName.Trim(); + while (result.EndsWith("*", StringComparison.Ordinal) || result.EndsWith("&", StringComparison.Ordinal)) + { + result = result.Substring(0, result.Length - 1).TrimEnd(); + } + foreach (string qualifier in new[] { "const ", "volatile " }) + { + if (result.StartsWith(qualifier, StringComparison.Ordinal)) + { + result = result.Substring(qualifier.Length).TrimStart(); + } + } + return result; + } + + /// + /// Extracts the underlying type from a debugger type-description output: + /// the typedef target, the head type name (when the debugger already expanded + /// a typedef), or the first base class. Returns null when the output describes + /// only itself. + /// Handles GDB "whatis"/"ptype" ("type = ..." prefix) and LLDB "type lookup". + /// + internal static string ExtractResolvedTypeName(string output, string queriedName) + { + if (String.IsNullOrWhiteSpace(output)) + { + return null; + } + string line = null; + foreach (string candidate in output.Split('\n')) + { + string trimmed = candidate.Trim(); + if (trimmed.Length > 0) + { + line = trimmed; + break; + } + } + if (line == null) + { + return null; + } + + // Debugger error prose ("error: use of undeclared identifier ...") has a + // top-level colon, which would make "error" parse as a head name. + if (line.StartsWith("error:", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("warning:", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (line.StartsWith("type = ", StringComparison.Ordinal)) + { + line = line.Substring("type = ".Length).Trim(); + } + + if (line.StartsWith("typedef ", StringComparison.Ordinal)) + { + // "typedef " (lldb) or "typedef " (C style). + // Strip the trailing alias only when it is a whole whitespace-separated + // token, so a target that merely ends with the alias name (ns::Alias) + // is kept intact. + string target = line.Substring("typedef ".Length).Trim().TrimEnd(';').Trim(); + if (target.Length > queriedName.Length && + target.EndsWith(queriedName, StringComparison.Ordinal) && + Char.IsWhiteSpace(target[target.Length - queriedName.Length - 1])) + { + target = target.Substring(0, target.Length - queriedName.Length).TrimEnd(); + } + return (target != queriedName) ? ValidatedTypeName(target) : null; + } + + foreach (string keyword in new[] { "class ", "struct " }) + { + if (line.StartsWith(keyword, StringComparison.Ordinal)) + { + line = line.Substring(keyword.Length).Trim(); + break; + } + } + + // Strip a "template<...> " prefix (lldb prints template specializations + // as "template<> class Foo : ..."). Only when '<' follows the keyword — + // a type genuinely named "template_something" must not be eaten. + if (line.StartsWith("template", StringComparison.Ordinal) && + line.Substring("template".Length).TrimStart().StartsWith("<", StringComparison.Ordinal)) + { + int d = 0; + int i = "template".Length; + for (; i < line.Length; i++) + { + if (line[i] == '<') { d++; } + else if (line[i] == '>') { d--; if (d == 0) { i++; break; } } + } + line = line.Substring(i).TrimStart(); + foreach (string keyword in new[] { "class ", "struct " }) + { + if (line.StartsWith(keyword, StringComparison.Ordinal)) + { + line = line.Substring(keyword.Length).Trim(); + break; + } + } + } + + // GDB prints template types with a substitution clause after the head name: + // "ItemStack [with T = int] : public ItemList {" + // Capture the parameter values and strip the clause; the base clause is + // printed with UNsubstituted parameters and needs them re-applied. + Dictionary templateParams = null; + int withPos = line.IndexOf("[with ", StringComparison.Ordinal); + if (withPos >= 0) + { + int closePos = line.IndexOf(']', withPos); + if (closePos > withPos) + { + string clause = line.Substring(withPos + "[with ".Length, closePos - withPos - "[with ".Length); + templateParams = new Dictionary(StringComparer.Ordinal); + int partStart = 0; + int d2 = 0; + for (int i = 0; i <= clause.Length; i++) + { + char c = i < clause.Length ? clause[i] : ','; + if (c == '<' || c == '(') { d2++; } + else if (c == '>' || c == ')') { d2--; } + else if (c == ',' && d2 == 0) + { + string part = clause.Substring(partStart, i - partStart); + int eq = part.IndexOf('='); + if (eq > 0) + { + string key = part.Substring(0, eq).Trim(); + string value = part.Substring(eq + 1).Trim(); + if (key.Length > 0 && value.Length > 0) + { + templateParams[key] = value; + } + } + partStart = i + 1; + } + } + line = (line.Substring(0, withPos) + line.Substring(closePos + 1)).Trim(); + } + } + + // line is now e.g. "TextItemList : public ItemList {" or + // "ItemList {" (typedef already expanded by the debugger). + // Find the head-name end: the base-clause ':' (template depth 0, + // skipping "::") or the opening '{'. + int colonPos = -1; + int headEnd = line.Length; + int depth = 0; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (c == '<') { depth++; } + else if (c == '>') { depth--; } + else if (c == '{') { headEnd = i; break; } + else if (c == ':' && depth == 0) + { + if (i + 1 < line.Length && line[i + 1] == ':') + { + i++; // scope operator + continue; + } + colonPos = i; + headEnd = i; + break; + } + } + + // If the debugger already resolved the queried name to another type + // (alias expansion), the head name is the answer. Even when a base + // clause follows, that is the base of the RESOLVED type, not of the + // queried one — returning the base would skip past the type whose + // rule we are looking for. + string headName = line.Substring(0, headEnd).Trim(); + if (headName.Length > 0 && headName != queriedName) + { + return ValidatedTypeName(headName); + } + + if (colonPos >= 0) + { + // First base class: cut at '{' and at the first top-level ','. + string bases = line.Substring(colonPos + 1); + int end = bases.Length; + depth = 0; + for (int i = 0; i < bases.Length; i++) + { + char c = bases[i]; + if (c == '<') { depth++; } + else if (c == '>') { depth--; } + else if ((c == ',' || c == '{') && depth == 0) + { + end = i; + break; + } + } + string firstBase = bases.Substring(0, end).Trim(); + // Strip access/virtual keywords to fixpoint: gdb prints + // "public virtual Base", lldb prints "virtual public Base". + bool strippedKeyword = true; + while (strippedKeyword) + { + strippedKeyword = false; + foreach (string keyword in new[] { "virtual", "public", "protected", "private" }) + { + if (firstBase.StartsWith(keyword + " ", StringComparison.Ordinal)) + { + firstBase = firstBase.Substring(keyword.Length + 1).TrimStart(); + strippedKeyword = true; + } + } + } + // Re-apply gdb's "[with ...]" template-parameter values: the base + // clause is printed with the bare parameter names (ItemList). + if (templateParams != null) + { + foreach (KeyValuePair param in templateParams) + { + firstBase = Regex.Replace(firstBase, @"\b" + Regex.Escape(param.Key) + @"\b", param.Value); + } + } + return ValidatedTypeName(firstBase); + } + + // The output names only the queried type itself, with no base clause. return null; } + /// + /// Returns when it has the shape of a plain type name, + /// null otherwise. Keeps debugger error prose (e.g. "no type was found ...") + /// from being handed back as a resolution or entering the cache and the log. + /// + private static string ValidatedTypeName(string name) + { + return (!String.IsNullOrEmpty(name) && s_safeTypeNameForQuery.IsMatch(name)) ? name : null; + } + + /// + /// Asks the debugger what a reported type name is (typedef target or first base + /// class) and returns the resolved name, or null. Results, including negative + /// resolutions, are cached for the session; transient query errors are not. + /// + private string ResolveTypeNameViaDebugger(string typeName) + { + if (String.IsNullOrEmpty(typeName) || + s_primitiveTypeNames.Contains(typeName) || + !s_safeTypeNameForQuery.IsMatch(typeName)) + { + return null; + } + if (_typeNameQueryCache.TryGetValue(typeName, out string cached)) + { + return cached; + } + + string resolved = null; + try + { + switch (_process.MICommandFactory.Mode) + { + case MIMode.Gdb: + // "whatis" is one line and resolves one typedef level. For a + // class it echoes the name back, so fall through to "ptype", + // whose first line carries the base-class clause. + resolved = ExtractResolvedTypeName(ConsoleCommandSync("whatis " + typeName), typeName); + if (resolved == null) + { + resolved = ExtractResolvedTypeName(ConsoleCommandSync("ptype " + typeName), typeName); + } + break; + case MIMode.Lldb: + resolved = ExtractResolvedTypeName(ConsoleCommandSync("type lookup " + typeName), typeName); + break; + default: + break; + } + } + catch (Exception e) + { + // Do not cache: the failure may be transient (e.g. the process was not + // stopped) rather than a genuine "no resolution" for this name. + // Task.Wait() wraps the real failure in an AggregateException — unwrap + // it so the log carries the actual message. + string message = (e as AggregateException)?.Flatten().InnerException?.Message ?? e.Message; + _process.Logger.NatvisLogger?.WriteLine(LogLevel.Verbose, "FindType: type name query for '{0}' failed: {1}", typeName, message); + return null; + } + + _typeNameQueryCache[typeName] = resolved; + return resolved; + } + + private string ConsoleCommandSync(string cmd) + { + string output = null; + Task task = Task.Run(async () => + { + output = await _process.ConsoleCmdAsync(cmd, allowWhileRunning: false, ignoreFailures: true); + }); + task.Wait(); + return output; + } + private string FormatValue(string format, IVariableInformation variable, IDictionary scopedNames, IDictionary intrinsics = null) { if (String.IsNullOrWhiteSpace(format)) diff --git a/src/MIDebugEngineUnitTests/NatvisTypeNameResolutionTest.cs b/src/MIDebugEngineUnitTests/NatvisTypeNameResolutionTest.cs new file mode 100644 index 000000000..6067ab492 --- /dev/null +++ b/src/MIDebugEngineUnitTests/NatvisTypeNameResolutionTest.cs @@ -0,0 +1,244 @@ +using Xunit; +using Microsoft.MIDebugEngine.Natvis; + +namespace MIDebugEngineUnitTests +{ + /// + /// Unit tests for and + /// (typedef / base-class name + /// resolution used by the FindType fallback). + /// + public class NatvisTypeNameResolutionTest + { + // -- StripTypeDecorations ---------------------------------------------- + + [Fact] + public void StripTypeDecorations_ConstRef_Stripped() + { + Assert.Equal("TextItemList", Natvis.StripTypeDecorations("const TextItemList &")); + } + + [Fact] + public void StripTypeDecorations_Pointer_Stripped() + { + Assert.Equal("TextItemList", Natvis.StripTypeDecorations("TextItemList *")); + } + + [Fact] + public void StripTypeDecorations_PlainName_Unchanged() + { + Assert.Equal("RawItemList", Natvis.StripTypeDecorations("RawItemList")); + } + + [Fact] + public void StripTypeDecorations_TemplateName_Unchanged() + { + Assert.Equal("ItemList", Natvis.StripTypeDecorations("ItemList")); + } + + // -- ExtractResolvedTypeName: GDB whatis -------------------------------- + + [Fact] + public void Extract_GdbWhatisTypedef_ReturnsTarget() + { + Assert.Equal("ItemList", + Natvis.ExtractResolvedTypeName("type = ItemList", "ItemValueList")); + } + + [Fact] + public void Extract_GdbWhatisEchoesName_ReturnsNull() + { + // whatis of a class echoes the class name back — no new information. + Assert.Null(Natvis.ExtractResolvedTypeName("type = TextItemList", "TextItemList")); + } + + // -- ExtractResolvedTypeName: GDB ptype --------------------------------- + + [Fact] + public void Extract_GdbPtypeSubclass_ReturnsFirstBase() + { + string output = "type = class TextItemList : public ItemList {\n public:\n ...\n}"; + Assert.Equal("ItemList", Natvis.ExtractResolvedTypeName(output, "TextItemList")); + } + + [Fact] + public void Extract_GdbPtypeTypedefExpanded_ReturnsHeadName() + { + // ptype of a typedef expands to the underlying class definition. + string output = "type = class ItemList {\n public:\n ...\n}"; + Assert.Equal("ItemList", Natvis.ExtractResolvedTypeName(output, "ItemValueList")); + } + + [Fact] + public void Extract_GdbPtypeNoBase_ReturnsNull() + { + string output = "type = class MyPlainType {\n int x;\n}"; + Assert.Null(Natvis.ExtractResolvedTypeName(output, "MyPlainType")); + } + + // -- ExtractResolvedTypeName: LLDB type lookup -------------------------- + + [Fact] + public void Extract_LldbTypedef_ReturnsTarget() + { + Assert.Equal("ItemList", + Natvis.ExtractResolvedTypeName("typedef ItemList", "ItemValueList")); + } + + [Fact] + public void Extract_LldbClassWithBase_ReturnsFirstBase() + { + string output = "class TextItemList : public ItemList {\n ...\n}"; + Assert.Equal("ItemList", Natvis.ExtractResolvedTypeName(output, "TextItemList")); + } + + [Fact] + public void Extract_LldbAliasExpandedWithItsOwnBase_ReturnsHeadNotBase() + { + // lldb resolves an alias by printing the underlying class definition — + // including THAT class's own base and a "template<>" prefix. The head + // name is the resolution; the base clause belongs to the resolved type. + string output = "template<> class ItemList : public ListSpecial {\n typedef Something DataPointer;\n}"; + Assert.Equal("ItemList", Natvis.ExtractResolvedTypeName(output, "TextItemList")); + } + + [Fact] + public void Extract_CStyleTypedefWithAliasSuffix_ReturnsTarget() + { + // "typedef " — the trailing alias must be stripped. + Assert.Equal("ItemList", + Natvis.ExtractResolvedTypeName("typedef ItemList ItemValueList", "ItemValueList")); + } + + // -- ExtractResolvedTypeName: base-clause parsing edge cases ------------ + + [Fact] + public void Extract_MultipleBases_ReturnsFirstOnly() + { + string output = "class Derived : public BaseA, protected BaseB {"; + Assert.Equal("BaseA", Natvis.ExtractResolvedTypeName(output, "Derived")); + } + + [Fact] + public void Extract_VirtualPublicBase_KeywordsStripped() + { + string output = "class Derived : virtual public Base {"; + Assert.Equal("Base", Natvis.ExtractResolvedTypeName(output, "Derived")); + } + + [Fact] + public void Extract_ScopedBaseName_ScopeOperatorNotMistakenForBaseClause() + { + string output = "class Derived : public ns::Base {"; + Assert.Equal("ns::Base", Natvis.ExtractResolvedTypeName(output, "Derived")); + } + + [Fact] + public void Extract_TemplateArgsContainingScopes_Parsed() + { + string output = "class Wrapper : public std::vector {"; + Assert.Equal("std::vector", Natvis.ExtractResolvedTypeName(output, "Wrapper")); + } + + [Fact] + public void Extract_GdbTemplateSubclassWithClause_ParamsSubstitutedInBase() + { + // GDB prints template types with a "[with ...]" substitution clause and + // leaves the base clause UNsubstituted (verified against gdb 16.3). + string output = "type = class ItemStack [with T = int] : public ItemList {\n ...\n}"; + Assert.Equal("ItemList", Natvis.ExtractResolvedTypeName(output, "ItemStack")); + } + + [Fact] + public void Extract_GdbWithClauseMultipleParams_AllSubstituted() + { + string output = "type = class PairLike [with K = int, V = long] : public MapBase {"; + Assert.Equal("MapBase", Natvis.ExtractResolvedTypeName(output, "PairLike")); + } + + [Fact] + public void Extract_GdbVirtualBaseKeywordOrder_Stripped() + { + // gdb emits "public virtual", lldb emits "virtual public" — both must strip. + string output = "type = class Derived : public virtual Base {"; + Assert.Equal("Base", Natvis.ExtractResolvedTypeName(output, "Derived")); + } + + [Fact] + public void Extract_GdbWithClauseNonTypeParam_Substituted() + { + // Non-type template parameters appear in the with-clause as values too. + string output = "type = class FixedArr [with T = int, N = 4] : public ArrBase {"; + Assert.Equal("ArrBase", Natvis.ExtractResolvedTypeName(output, "FixedArr")); + } + + [Fact] + public void Extract_GdbVirtualBaseCombinedWithClause_Substituted() + { + // Keyword stripping and with-clause substitution must compose. + string output = "type = class ItemStack [with T = int] : public virtual ItemList {"; + Assert.Equal("ItemList", Natvis.ExtractResolvedTypeName(output, "ItemStack")); + } + + [Fact] + public void Extract_StructKeyword_Handled() + { + string output = "type = struct DerivedS : public BaseS {"; + Assert.Equal("BaseS", Natvis.ExtractResolvedTypeName(output, "DerivedS")); + } + + [Fact] + public void Extract_TypeNamedTemplateHelper_NotTreatedAsTemplatePrefix() + { + // A type genuinely named "template_helper" must not have its name eaten + // by the template<...> prefix stripping. + string output = "class template_helper : public Base {"; + Assert.Equal("Base", Natvis.ExtractResolvedTypeName(output, "template_helper")); + } + + [Fact] + public void Extract_ScopedTypedefTargetEndingWithAlias_TargetKept() + { + // The alias suffix is only stripped as a whole whitespace-separated token: + // a target that merely ends with the alias name must stay intact. + Assert.Equal("ns::ItemValueList", + Natvis.ExtractResolvedTypeName("typedef ns::ItemValueList ItemValueList", "ItemValueList")); + } + + // -- ExtractResolvedTypeName: degenerate inputs ------------------------- + + [Fact] + public void Extract_EmptyOrWhitespace_ReturnsNull() + { + Assert.Null(Natvis.ExtractResolvedTypeName(null, "X")); + Assert.Null(Natvis.ExtractResolvedTypeName("", "X")); + Assert.Null(Natvis.ExtractResolvedTypeName(" \n ", "X")); + } + + [Fact] + public void Extract_ErrorText_ReturnsNullOrHarmless() + { + // A "no type found" style message must not be mistaken for a type name + // that could loop; it differs from the queried name, so the caller's + // TypeName.Parse / Scan will simply find no rule for it. + Assert.Null(Natvis.ExtractResolvedTypeName("type = MyPlainType", "MyPlainType")); + } + + [Fact] + public void Extract_ErrorPrefixedOutput_ReturnsNull() + { + // "error: ..." has a top-level colon; without the guard, "error" would + // parse as a head name and waste a follow-up query. + Assert.Null(Natvis.ExtractResolvedTypeName("error: use of undeclared identifier 'x'", "Foo")); + Assert.Null(Natvis.ExtractResolvedTypeName("warning: something", "Foo")); + } + + [Fact] + public void Extract_LldbNoTypeFoundMessage_ReturnsNull() + { + // Debugger error prose must not come back as a "resolved type name". + string output = "no type was found in the current language c++14 matching 'Foo'; performing a global search across all languages"; + Assert.Null(Natvis.ExtractResolvedTypeName(output, "Foo")); + } + } +} From 38ded545e4deb4f2080cefc439d21c67d63b9965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Mon, 6 Jul 2026 15:25:48 +0200 Subject: [PATCH 2/3] Recognize base-class children that lldb reports with a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base-class detection required the child to have no value — a gdb convention. lldb reports base-class children with an aggregate value ("{...}"), so they were classified as plain fields: name substitution did not search them for inherited members, and their full name produced an invalid expression. Natvis rules matched through a base class therefore failed to evaluate on subclass variables. Fix: additionally classify a child as a base class when its expression string is identical to its type name and contains '<', ':' or a space, regardless of the value. Those characters can occur in a type name but never in a data member's name, which is an identifier — so only base-class children can match, and no real member can be misclassified. Known gap: a base whose name is a plain identifier (Derived : Base), reported by lldb with a value, is still classified as a field — accepting it would risk misclassifying a member named after its type. --- src/MIDebugEngine/Engine.Impl/Variables.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/MIDebugEngine/Engine.Impl/Variables.cs b/src/MIDebugEngine/Engine.Impl/Variables.cs index 031dd40c8..d5b593bfc 100644 --- a/src/MIDebugEngine/Engine.Impl/Variables.cs +++ b/src/MIDebugEngine/Engine.Impl/Variables.cs @@ -297,10 +297,17 @@ private VariableInformation(TupleValue results, VariableInformation parent, stri int index; - if (!results.Contains("value") && (Name == TypeName || (Name.Contains("::") && name == null))) + if ((!results.Contains("value") && (Name == TypeName || (Name.Contains("::") && name == null))) + || (Name == TypeName && Name.IndexOfAny(s_typeNameOnlyChars) >= 0)) { - // base classes show up with no value and exp==type + // base classes show up with no value and exp==type // (sometimes underlying debugger does not follow this convention, when using typedefs in templated types so look for "::" in the field name too) + // lldb reports base-class children WITH an aggregate value ("{...}"), so + // additionally treat exp==type as a base class whenever the name cannot + // be a field identifier (template arguments, scope operator, space). + // Known gap: a base whose name is a plain identifier, reported with a + // value, stays classified as a field — accepting it would risk + // misclassifying a member named after its type. Name = TypeName + " (base)"; Value = TypeName; VariableNodeType = NodeType.BaseClass; @@ -392,6 +399,10 @@ public enum NodeType public NodeType VariableNodeType { get; private set; } + // Characters that can appear in a type name but never in a field identifier — + // used to recognize base-class children whose expression equals their type name. + private static readonly char[] s_typeNameOnlyChars = new char[] { '<', ':', ' ' }; + private static readonly string[] s_stringTypes = new string[] { @"^char *\*$", @"^char *\[[0-9]*\]$", From d00b0ee4d0c291d362e07d917f814b2200e9c448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucie=20G=C3=A9rard?= Date: Wed, 8 Jul 2026 17:16:00 +0200 Subject: [PATCH 3/3] Natvis: add end-to-end test for typedef and base-class resolution Add a typedef alias and a subclass of a visualized template type to the natvis debuggee, and verify both display through the underlying type's visualizer, exercising the full debugger round-trip of the type-name resolution. --- test/CppTests/Tests/NatvisTests.cs | 44 ++++++++++++++++++- .../debuggees/natvis/src/SimpleTemplated.h | 12 +++++ test/CppTests/debuggees/natvis/src/main.cpp | 3 ++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/test/CppTests/Tests/NatvisTests.cs b/test/CppTests/Tests/NatvisTests.cs index 46f31299c..5a30d8b50 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 = 66; - private const int ReturnSourceLine = 90; + private const int ReturnSourceLine = 93; [Theory] [RequiresTestSettings] @@ -674,6 +674,48 @@ public void TestHideRawView(ITestSettings settings) } } + [Theory] + [DependsOnTest(nameof(CompileNatvisDebuggee))] + [RequiresTestSettings] + public void TestTypedefAndBaseClassResolution(ITestSettings settings) + { + this.TestPurpose("This test checks that a typedef alias and a subclass of a visualized type resolve to the underlying type's visualizer."); + 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 a variable declared via a typedef alias uses the underlying type's visualizer"); + var aliased = currentFrame.GetVariable("aliasedContainer"); + Assert.Equal("{ size=4 }", aliased.Value); + + this.Comment("Verifying a variable of a subclass type uses the base class's visualizer"); + var derived = currentFrame.GetVariable("derivedContainer"); + Assert.Equal("{ size=6 }", derived.Value); + } + + runner.Expects.ExitedEvent(exitCode: 0).TerminatedEvent().AfterContinue(); + runner.DisconnectAndVerify(); + } + } + [Theory] [DependsOnTest(nameof(CompileNatvisDebuggee))] [RequiresTestSettings] diff --git a/test/CppTests/debuggees/natvis/src/SimpleTemplated.h b/test/CppTests/debuggees/natvis/src/SimpleTemplated.h index 68b072e1f..040902860 100644 --- a/test/CppTests/debuggees/natvis/src/SimpleTemplated.h +++ b/test/CppTests/debuggees/natvis/src/SimpleTemplated.h @@ -33,3 +33,15 @@ class SimpleMap Value _value; int _size; }; + +// A typedef alias and a subclass of SimpleContainer: the debugger reports +// variables of these types under the alias/subclass name, which matches no +// visualizer directly — resolving them to SimpleContainer<*> exercises the +// typedef and base-class type-name resolution. +typedef SimpleContainer ContainerAlias; + +class DerivedContainer : public SimpleContainer +{ +public: + DerivedContainer(int val, int count) : SimpleContainer(val, count) {} +}; diff --git a/test/CppTests/debuggees/natvis/src/main.cpp b/test/CppTests/debuggees/natvis/src/main.cpp index 1216a3e13..c428460b4 100644 --- a/test/CppTests/debuggees/natvis/src/main.cpp +++ b/test/CppTests/debuggees/natvis/src/main.cpp @@ -87,5 +87,8 @@ int main(int argc, char** argv) inactiveList.Add(100); inactiveList.Add(200); + ContainerAlias aliasedContainer(9, 4); + DerivedContainer derivedContainer(7, 6); + return 0; }