Skip to content

Fix C/C++ debug hover on intermediate members of a dereferenced expression#14540

Open
tieo wants to merge 2 commits into
microsoft:mainfrom
tieo:cpp-debug-hover-leading-deref
Open

Fix C/C++ debug hover on intermediate members of a dereferenced expression#14540
tieo wants to merge 2 commits into
microsoft:mainfrom
tieo:cpp-debug-hover-leading-deref

Conversation

@tieo

@tieo tieo commented Jun 23, 2026

Copy link
Copy Markdown

Problem

While debugging C/C++, hovering an intermediate member of a */&-prefixed access chain (e.g. a or b in if (*a.b.c == X) where a.b is a struct) shows no value, and hovering a member that comes after an array subscript (e.g. c in a.b[i].c) shows no value either.

Cause

No EvaluatableExpressionProvider is registered for C/C++, so VS Code's built-in data-tip expression detection is used. It keeps the leading */& and clips on the right, so hovering b in *a.b.c evaluates *a.b — a dereference of the non-pointer member a.b — which errors. It also stops at [ and ], so hovering c in a.b[i].c evaluates a broken fragment after the ].

Fix

Register an EvaluatableExpressionProvider for C/C++. Once a provider is registered, VS Code uses it instead of its built-in detection (it does not fall back to the default when the provider returns nothing), so the provider reproduces the built-in behavior for ordinary tokens and additionally fixes the access-chain cases:

  • A leading */& applies to the whole access chain (the postfix ., ->, [] operators bind tighter). It is dropped for an interior member of a . chain, where *a.b would dereference the struct a.b, and kept on the final member and before -> (so *ptr->member is unchanged).
  • Array subscripts and :: are kept in the token, so members after a subscript (a.b[i].c) and scoped names (ns::var) resolve. Hovering a subscript bracket evaluates the indexed element; hovering inside [...] evaluates the index on its own.

The provider is registered during debugger activation (so it also works when cpptools IntelliSense is disabled), and the expression computation lives in a vscode-free module (evaluatableExpression.ts) with unit tests.

Note: I created the code in this PR with the help of an LLM, and tested it manually in VS Code plus with the added unit tests.

…enced members

VS Code's default debug data-tip keeps a leading `*`/`&` and clips on the right, so hovering
an intermediate member of e.g. `*a.b.c` evaluates `*a.b` (a dereference of the struct `a.b`)
and shows no value.

Register an EvaluatableExpressionProvider that, only for that case, returns the expression
without the leading operator. Every other expression returns undefined so the default
behavior is unchanged.
@tieo tieo requested a review from a team as a code owner June 23, 2026 10:35
@github-project-automation github-project-automation Bot moved this to Pull Request in cpptools Jun 23, 2026
@tieo

tieo commented Jun 23, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Comment thread Extension/src/LanguageServer/client.ts Outdated
Comment thread Extension/src/LanguageServer/Providers/evaluatableExpressionProvider.ts Outdated
sean-mcmanus

This comment was marked as resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Registers a C/C++ EvaluatableExpressionProvider so debug hover evaluates the correct sub-expression when the hovered identifier is an intermediate member inside a */&-prefixed access chain (e.g. avoiding *a.b when hovering b in *a.b.c).

Changes:

  • Added EvaluatableExpressionProvider implementation that conditionally strips leading */& for intermediate member hovers.
  • Registered the provider during language server client initialization for the C/C++ document selector.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
Extension/src/LanguageServer/Providers/evaluatableExpressionProvider.ts Adds a custom evaluatable-expression computation to override VS Code’s default behavior in a narrow case.
Extension/src/LanguageServer/client.ts Registers the new evaluatable expression provider for C/C++ documents.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Extension/src/LanguageServer/Providers/evaluatableExpressionProvider.ts Outdated
@sean-mcmanus sean-mcmanus added this to the 1.33.2 milestone Jun 23, 2026
@tieo

tieo commented Jun 25, 2026

Copy link
Copy Markdown
Author

Addressed the review feedback in the latest commit:

  • Moved the provider to Extension/src/Debugger/ and register it during debugger activation in Debugger/extension.ts instead of the language client.

On the Copilot suggestion to gate with line.charAt(clipEnd) === '.': I went a different way. The problem with that gate is it leaves *ptr->member doing what the default does, but that's already broken — -> has higher precedence than the unary *, so the expression is really *(ptr->member), and when you hover ptr you want to see ptr, not *ptr.

What it does now is keep the leading */& only when you're hovering the last segment of the chain, and drop it everywhere in the middle. That ends up being right whether the chain uses ., ->, or []. I also had to keep subscripts in the token because the default detection stops at the [, which is why hovering something like the c in a.b[i].c gave you nothing before. Function calls never get pulled in, so there's no risk of evaluating something with side effects.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

Comment on lines +75 to +79
const defaultExpression = (): vscode.EvaluatableExpression =>
new vscode.EvaluatableExpression(new vscode.Range(position.line, tokenStart, position.line, clipEnd), line.substring(tokenStart, clipEnd));

if (leading === null) {
return defaultExpression();

@sean-mcmanus sean-mcmanus Jun 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this isn't 100% resolved yet:

But the actual concern the comment raised — "can diverge from VS Code's built-in detection" — is still live. There's no parity test, and the divergence is real: tokens can begin with a connector, so foo().bar / (*this).member / obj->fn()->field return broken fragments (.bar, .member, ->field). So the intent is addressed and documented, but the divergence it warned about is not eliminated.

This is the same as the two new issues raised by the GitHub Copilot.

Comment thread Extension/src/Debugger/evaluatableExpressionProvider.ts Outdated
Comment thread Extension/src/Debugger/evaluatableExpressionProvider.ts Outdated
Comment thread Extension/src/Debugger/evaluatableExpressionProvider.ts
sean-mcmanus

This comment was marked as resolved.

Register the EvaluatableExpressionProvider during debugger activation
instead of through the language client, so it also works when IntelliSense
is disabled, and move it next to the other debugger code.

The expression computation lives in a vscode-free module
(evaluatableExpression.ts) so it can be unit tested directly. Registering
a provider replaces VS Code's built-in data-tip expression detection, so
it reproduces that for ordinary tokens and additionally handles access
chains the built-in detection gets wrong for C/C++:

- A leading */& binds to the whole access chain (the postfix ., ->, []
  operators bind tighter), so it is dropped for an interior member of a
  dot chain, where *a.b would dereference the struct a.b, and kept on the
  final member and before ->.

- Array subscripts and :: are kept in the token, so members after a
  subscript (a.b[i].c) and scoped names (ns::var) resolve instead of
  producing a broken fragment.
@tieo tieo force-pushed the cpp-debug-hover-leading-deref branch from 5433ec8 to b080bf6 Compare June 26, 2026 11:47
@tieo

tieo commented Jun 26, 2026

Copy link
Copy Markdown
Author

Thanks — addressed the feedback and rewrote the description, since the old one no longer matched the code.

On the "returns undefined otherwise" point: that was the original intent, but it doesn't actually work. Once an EvaluatableExpressionProvider is registered, VS Code uses it instead of its built-in detection and does not fall back to the default when the provider returns undefined — the data-tip just shows nothing. I verified this by running the resolver's own logic: with a provider registered that returns undefined, it returns null (no tip); the built-in extraction only runs when no provider is registered at all. So the provider has to return a sensible expression for ordinary tokens, not undefined. I updated the description and comments to say this.

On ::: added to the tokenizer, so ns::var stays together.

On <= vs < in the hit-test: I kept <=. VS Code's own getExactExpressionStartAndEnd uses an inclusive end (l >= e), which maps to <= here — so <= matches the built-in detection, and < would diverge by failing to select a token when hovering its trailing edge.

On tests: moved the computation into a vscode-free module (evaluatableExpression.ts) and added unit tests covering *a.b.c interior vs final, ->, ::, subscripts (a.b[i].c, bracket vs index), and the no-token case.

@sean-mcmanus sean-mcmanus self-requested a review June 26, 2026 16:14
// In each input the `|` marks the cursor; it is removed before evaluating.
function evaluate(marked: string): string | undefined {
const character: number = marked.indexOf('|');
const line: string = marked.replace('|', '');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL js/incomplete-sanitization (test helper)

It's a benign warning on test code, but trivially silenced and made more robust by slicing at the known marker index instead of String.replace, which also documents the single-marker assumption:

function evaluate(marked: string): string | undefined {
    const character: number = marked.indexOf('|');
    const line: string = marked.slice(0, character) + marked.slice(character + 1);
    return computeEvaluatableExpression(line, character)?.expression;
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +42 to +45
const tokenStart: number = token.index;
const tokenEnd: number = token.index + token[0].length;
const leading: RegExpMatchArray | null = token[0].match(/^[*&]+/);
const exprStart: number = tokenStart + (leading !== null ? leading[0].length : 0);
Comment on lines +63 to +67
it('keeps :: scoped names together', () => {
strictEqual(evaluate('ns::|var'), 'ns::var');
strictEqual(evaluate('ns::var::|z'), 'ns::var::z');
});
});

@sean-mcmanus sean-mcmanus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My local Copilot says the following (the 1st issue is the same as the 2 comments made by the GitHub Copilot).

Significant: tokens can begin with a connector, producing broken expressions

The token grammar lets a match start with ., ->, or ::, because the repeated group's alternatives are tried independently:

/(?:[*&]+)?(?:[\p{L}\p{N}_]+|->|::|\.|\[[^\][]*\])+/gu

For any expression where a primary isn't a bare identifier — parenthesized or call results — the regex skips the un-matchable (/) and starts a fresh token at the connector. Tracing foo().bar:

  • foo matches, stops at (
  • ( and ) are unmatchable, scanned past
  • match resumes at . → token .bar

Hovering bar returns .bar, which gets sent to the debugger and errors. Same for:

  • (*this).member → hovering member.member
  • obj->method()->field → hovering field->field

These are common patterns, and built-in detection likely produced bar/member/field for them, so this is a regression risk, not just an edge case. Fix: anchor the chain so it must start with an identifier (or leading op + identifier), e.g.

/(?:[*&]+)?[\p{L}\p{N}_]+(?:[\p{L}\p{N}_]+|->|::|\.|\[[^\][]*\])*/gu

and add tests for foo().bar, (*this).member, and obj->fn()->field so a leading-connector expression can never be returned. (Optional, while you're there: tightening the identifier start to [\p{L}_][\p{L}\p{N}_]* would also stop 1.5/0x1F numeric literals from being tokenized as member chains — a smaller parity divergence.)

Confirm intent: *ptr->member hovering ptr*ptr

The new test locks in evaluate('*|ptr->member') === '*ptr'. Worth flagging because in an earlier note you argued the opposite — "when you hover ptr you want to see ptr, not *ptr." The current behavior is actually defensible: ptr-> proves ptr is a pointer, so *ptr is a valid dereference (it won't error the way *a.b does), and it matches built-in's keep-leading-and-clip-right behavior. But it does show the pointee rather than the pointer the user pointed at. The same family of cases is also now locked in by the .-only drop:

  • *a.b[i] hovering b*a.b (valid — array decays — but shows the first element)
  • &a[i] hovering a&a (address of the array)

None of these error, so they don't reintroduce the "shows nothing" bug, but they're semantically surprising. If *ptr/*a.b/&a are the intended answers, fine — just make it a deliberate, documented choice rather than a side effect of the charAt(clipEnd) !== '.' gate. A one-line comment on why . is special (it's the only connector whose left operand isn't provably a pointer, so it's the only one where keeping * would error) would make this self-explanatory.

Summary

  • Must fix: anchor the token so it can't start with ./->/:: (avoids returning .bar/->field for foo().bar, (*this).member, etc.); add tests for those.
  • Confirm + comment: the *ptr/*a.b/&a keep-leading outcomes are intentional (they don't error, but they're surprising) — add a one-line rationale for the .-only drop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Pull Request

Development

Successfully merging this pull request may close these issues.

4 participants