Skip to content

[Bug] PrivateIdentifier nodes are omitted from 2020 semantic classifications #64322

Description

@VALLIS-NERIA

Bug Report

🔎 Search Terms

semantic highlighting, semantic token, PrivateIdentifier, private field, private method, encodedSemanticClassifications

🕗 Version & Regression Information

  • TypeScript 6.0.3 (bundled with VS Code)
  • Also present in the v5.9 classifier implementation
  • This is not a theme-specific issue

💻 Code

class Foo {
    field = 1;
    #privateField = 1;

    method() {}
    #privateMethod() {}

    test() {
        this.field;
        this.#privateField;

        this.method();
        this.#privateMethod();
    }
}

🙁 Actual behavior

In VS Code's Developer: Inspect Editor Tokens and Scopes:

  • field and method receive semantic token types.
  • #privateField and #privateMethod receive no semantic token and fall back to TextMate scopes.
  • As a result, editor.semanticTokenColorCustomizations rules for property and method do not apply to ECMAScript private members.
Image Image

🙂 Expected behavior

  • #privateField should be classified as property.
  • #privateMethod should be classified as method.
  • Declarations should also receive the declaration modifier.

Root cause

VS Code requests encodedSemanticClassifications-full with format "2020".

In src/services/classifier2020.ts, collectTokens only enters the semantic-classification path for isIdentifier(node):

if (isIdentifier(node) && ...) {
    let symbol = typeChecker.getSymbolAtLocation(node);
    // ...
}

ECMAScript private names are separate SyntaxKind.PrivateIdentifier nodes, so they are skipped before getSymbolAtLocation is called.

Relevant source:

function collectTokens(program: Program, sourceFile: SourceFile, span: TextSpan, collector: (node: Node, tokenType: number, tokenModifier: number) => void, cancellationToken: CancellationToken) {
const typeChecker = program.getTypeChecker();
let inJSXElement = false;
function visit(node: Node) {
switch (node.kind) {
case SyntaxKind.ModuleDeclaration:
case SyntaxKind.ClassDeclaration:
case SyntaxKind.InterfaceDeclaration:
case SyntaxKind.FunctionDeclaration:
case SyntaxKind.ClassExpression:
case SyntaxKind.FunctionExpression:
case SyntaxKind.ArrowFunction:
cancellationToken.throwIfCancellationRequested();
}
if (!node || !textSpanIntersectsWith(span, node.pos, node.getFullWidth()) || node.getFullWidth() === 0) {
return;
}
const prevInJSXElement = inJSXElement;
if (isJsxElement(node) || isJsxSelfClosingElement(node)) {
inJSXElement = true;
}
if (isJsxExpression(node)) {
inJSXElement = false;
}
if (isIdentifier(node) && !inJSXElement && !inImportClause(node) && !isInfinityOrNaNString(node.escapedText)) {
let symbol = typeChecker.getSymbolAtLocation(node);
if (symbol) {
if (symbol.flags & SymbolFlags.Alias) {
symbol = typeChecker.getAliasedSymbol(symbol);
}
let typeIdx = classifySymbol(symbol, getMeaningFromLocation(node));
if (typeIdx !== undefined) {
let modifierSet = 0;
if (node.parent) {
const parentIsDeclaration = isBindingElement(node.parent) || tokenFromDeclarationMapping.get(node.parent.kind) === typeIdx;
if (parentIsDeclaration && (node.parent as NamedDeclaration).name === node) {
modifierSet = 1 << TokenModifier.declaration;
}
}
// property declaration in constructor
if (typeIdx === TokenType.parameter && isRightSideOfQualifiedNameOrPropertyAccess(node)) {
typeIdx = TokenType.property;
}
typeIdx = reclassifyByType(typeChecker, node, typeIdx);
const decl = symbol.valueDeclaration;
if (decl) {
const modifiers = getCombinedModifierFlags(decl);
const nodeFlags = getCombinedNodeFlags(decl);
if (modifiers & ModifierFlags.Static) {
modifierSet |= 1 << TokenModifier.static;
}
if (modifiers & ModifierFlags.Async) {
modifierSet |= 1 << TokenModifier.async;
}
if (typeIdx !== TokenType.class && typeIdx !== TokenType.interface) {
if ((modifiers & ModifierFlags.Readonly) || (nodeFlags & NodeFlags.Const) || (symbol.getFlags() & SymbolFlags.EnumMember)) {
modifierSet |= 1 << TokenModifier.readonly;
}
}
if ((typeIdx === TokenType.variable || typeIdx === TokenType.function) && isLocalDeclaration(decl, sourceFile)) {
modifierSet |= 1 << TokenModifier.local;
}
if (program.isSourceFileDefaultLibrary(decl.getSourceFile())) {
modifierSet |= 1 << TokenModifier.defaultLibrary;
}
}
else if (symbol.declarations && symbol.declarations.some(d => program.isSourceFileDefaultLibrary(d.getSourceFile()))) {
modifierSet |= 1 << TokenModifier.defaultLibrary;
}
collector(node, typeIdx, modifierSet);
}
}
}
forEachChild(node, visit);

Proposed fix

+    isPrivateIdentifier,
     isPropertyAccessExpression,
-        if (isIdentifier(node) && ...) {
+        if ((isIdentifier(node) || isPrivateIdentifier(node)) && ...) {

The existing declaration-kind mapping already classifies private fields and methods correctly once these nodes reach the symbol-classification path.

I tested the equivalent change against TypeScript 6.0.3:

  • #privateField declaration/reference → property
  • #privateMethod declaration/reference → member internally, mapped by VS Code to method
  • declaration occurrences receive the declaration modifier
  • added fourslash regression test passes

Related issue

#44483 requests new private/protected semantic modifiers. This report is different: ECMAScript PrivateIdentifier nodes currently receive no semantic classification at all.

Activity

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

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions