8000 feat(language-service): add semantic tokens for templates by JannikLassahn · Pull Request #60260 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content

feat(language-service): add semantic tokens for templates #60260

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat(language-service): add semantic tokens for templates
Adds support for `getEncodedSemanticClassifications` to the language service.
The service now classifies components in a template as the `class` type.
  • Loading branch information
JannikLassahn authored and thePunderWoman committed Jun 24, 2025
commit 17f0ee81ad2bb7ae5bf2ba17547d7a02691b1313
15 changes: 10 additions & 5 deletions packages/compiler-cli/src/ngtsc/perf/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,6 @@ export enum PerfPhase {
*/
OutliningSpans,

/**
* Tracks the number of `PerfPhase`s, and must appear at the end of the list.
*/
LAST,

/**
* Time spent by the Angular Language Service calculating code fixes.
*/
Expand All @@ -178,6 +173,16 @@ export enum PerfPhase {
* Time spent computing changes for applying a given refactoring.
*/
LSApplyRefactoring,

/**
* Time spent by the Angular Language Service calculating semantic classifications.
*/
LSSemanticClassification,

/**
* Tracks the number of `PerfPhase`s, and must appear at the end of the list.
*/
LAST,
}

/**
Expand Down
75 changes: 74 additions & 1 deletion packages/language-service/src/language_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ import {
getParentClassDeclaration,
getPropertyAssignmentFromValue,
} from './utils/ts_utils';
import {getTypeCheckInfoAtPosition, isTypeScriptFile} from './utils';
import {getTypeCheckInfoAtPosition, isTypeScriptFile, TypeCheckInfo} from './utils';
import {ActiveRefactoring, allRefactorings} from './refactorings/refactoring';
import {getClassificationsForTemplate} from './semantic_tokens';
import {isExternalResource} from '@angular/compiler-cli/src/ngtsc/metadata';

type LanguageServiceConfig = Omit<PluginConfig, 'angularOnly'>;

Expand Down Expand Up @@ -290,6 +292,77 @@ export class LanguageService {
);
}

getEncodedSemanticClassifications(
fileName: string,
span: ts.TextSpan,
format: ts.SemanticClassificationFormat | undefined,
): ts.Classifications {
return this.withCompilerAndPerfTracing(PerfPhase.LSSemanticClassification, (compiler) => {
return this.getEncodedSemanticClassificationsImpl(fileName, span, format, compiler);
});
}

private getEncodedSemanticClassificationsImpl(
fileName: string,
span: ts.TextSpan,
format: ts.SemanticClassificationFormat | undefined,
compiler: NgCompiler,
): ts.Classifications {
if (format == ts.SemanticClassificationFormat.Original) {
return {spans: [], endOfLineState: ts.EndOfLineState.None};
}

if (isTypeScriptFile(fileName)) {
const sf = compiler.getCurrentProgram().getSourceFile(fileName);
if (sf === undefined) {
return {spans: [], endOfLineState: ts.EndOfLineState.None};
}

const classDeclarations: ts.ClassDeclaration[] = [];
sf.forEachChild((node) => {
if (ts.isClassDeclaration(node)) {
classDeclarations.push(node);
}
});

const hasInlineTemplate = (classDecl: ts.ClassDeclaration) => {
const resources = compiler.getDirectiveResources(classDecl);
return resources && resources.template && !isExternalResource(resources.template);
};

const typeCheckInfos: TypeCheckInfo[] = [];
const templateChecker = compiler.getTemplateTypeChecker();

for (const classDecl of classDeclarations) {
if (!hasInlineTemplate(classDecl)) {
continue;
}
const template = templateChecker.getTemplate(classDecl);
if (template !== null) {
typeCheckInfos.push({
nodes: template,
declaration: classDecl,
});
}
}

const spans = [];
for (const templInfo of typeCheckInfos) {
const classifications = getClassificationsForTemplate(compiler, templInfo, span);
spans.push(...classifications.spans);
}

return {spans, endOfLineState: ts.EndOfLineState.None};
} else {
const typeCheckInfo = getTypeCheckInfoAtPosition(fileName, span.start, compiler);
if (typeCheckInfo === undefined) {
return {spans: [], endOfLineState: ts.EndOfLineState.None};
}

return getClassificationsForTemplate(compiler, typeCheckInfo, span);
}
}

getCompletionsAtPosition(
fileName: string,
position: number,
Expand Down
222 changes: 222 additions & 0 deletions packages/language-service/src/semantic_tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {
TmplAstElement,
TmplAstNode,
TmplAstTemplate,
TmplAstVisitor,
TmplAstBoundAttribute,
TmplAstBoundEvent,
TmplAstBoundText,
TmplAstContent,
TmplAstDeferredBlock,
TmplAstDeferredBlockError,
TmplAstDeferredBlockLoading,
TmplAstDeferredBlockPlaceholder,
TmplAstDeferredTrigger,
TmplAstForLoopBlock,
TmplAstForLoopBlockEmpty,
TmplAstIcu,
TmplAstIfBlock,
TmplAstIfBlockBranch,
TmplAstLetDeclaration,
TmplAstReference,
TmplAstSwitchBlock,
TmplAstSwitchBlockCase,
TmplAstText,
TmplAstTextAttribute,
TmplAstUnknownBlock,
TmplAstVariable,
TmplAstComponent,
TmplAstDirective,
ParseSourceSpan,
} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {PotentialDirective} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import ts from 'typescript';
import {TypeCheckInfo} from './utils';

/**
* see https://github.com/microsoft/TypeScript/blob/c85e626d8e17427a6865521737b45ccbbe9c78ef/src/services/classifier2020.ts#L49
*/
export const enum TokenEncodingConsts {
typeOffset = 8,
modifierMask = (1 << typeOffset) - 1,
}

/**
* Token types extended from TypeScript
* see https://github.com/microsoft/TypeScript/blob/c85e626d8e17427a6865521737b45ccbbe9c78ef/src/services/classifier2020.ts#L55
*/
export const enum TokenType {
class,
enum,
interface,
namespace,
typeParameter,
type,
parameter,
variable,
enumMember,
property,
function,
member,
}

/**
* Token modifiers extended from TypeScript
* see https://github.com/microsoft/TypeScript/blob/c85e626d8e17427a6865521737b45ccbbe9c78ef/src/services/classifier2020.ts#L71
*/
export const enum TokenModifier {
declaration,
static,
async,
readonly,
defaultLibrary,
local,
}

export function getClassificationsForTemplate(
compiler: NgCompiler,
typeCheckInfo: TypeCheckInfo,
range: ts.TextSpan,
): ts.Classifications {
const templateTypeChecker = compiler.getTemplateTypeChecker();
const potentialTags = templateTypeChecker.getElementsInFileScope(typeCheckInfo.declaration);

const visitor = new ClassificationVisitor(potentialTags, range);
visitor.visitAll(typeCheckInfo.nodes);

return {
spans: visitor.getSpans(),
endOfLineState: ts.EndOfLineState.None,
};
}

class ClassificationVisitor implements TmplAstVisitor {
private spans: number[] = [];
constructor(
private tags: Map<string, PotentialDirective | null>,
private range: ts.TextSpan,
) {}

getSpans(): number[] {
return this.spans;
}

visit(node: TmplAstNode | null) {
if (node && this.rangeIntersectsWith(node.sourceSpan)) {
node.visit(this);
}
}

visitElement(element: TmplAstElement) {
const tag = element.name;
const potentialDirective = this.tags.get(tag);
// prevent classification of non-component directives that would be applied
// to this element due to a matching selector
const isComponent = potentialDirective && potentialDirective.isComponent;
const classification = this.classifyAs(TokenType.class);

if (isComponent && this.rangeIntersectsWith(element.startSourceSpan)) {
this.spans.push(element.startSourceSpan.start.offset + 1, tag.length, classification);
}

this.visitAll(element.children);

if (isComponent && !element.isSelfClosing && this.rangeIntersectsWith(element.endSourceSpan!)) {
this.spans.push(element.endSourceSpan!.start.offset + 2, tag.length, classification);
}
}

visitContent(content: TmplAstContent) {
this.visitAll(content.children);
}

visitVariable(variable: TmplAstVariable) {}
visitReference(reference: TmplAstReference) {}
visitTextAttribute(attribute: TmplAstTextAttribute) {}
visitBoundAttribute(attribute: TmplAstBoundAttribute) {}
visitBoundEvent(attribute: TmplAstBoundEvent) {}
visitText(text: TmplAstText) {}
visitBoundText(text: TmplAstBoundText) {}
visitIcu(icu: TmplAstIcu) {}

visitDeferredBlock(deferred: TmplAstDeferredBlock) {
this.visitAll(deferred.children);
this.visit(deferred.error);
this.visit(deferred.loading);
this.visit(deferred.placeholder);
}

visitDeferredBlockPlaceholder(block: TmplAstDeferredBlockPlaceholder) {
this.visitAll(block.children);
}

visitDeferredBlockError(block: TmplAstDeferredBlockError) {
this.visitAll(block.children);
}

visitDeferredBlockLoading(block: TmplAstDeferredBlockLoading) {
this.visitAll(block.children);
}

visitDeferredTrigger(trigger: TmplAstDeferredTrigger) {}

visitSwitchBlock(block: TmplAstSwitchBlock) {
this.visitAll(block.cases);
}

visitSwitchBlockCase(block: TmplAstSwitchBlockCase) {
this.visitAll(block.children);
}

visitForLoopBlock(block: TmplAstForLoopBlock) {
this.visitAll(block.children);
this.visit(block.empty);
}

visitForLoopBlockEmpty(block: TmplAstForLoopBlockEmpty) {
this.visitAll(block.children);
}

visitIfBlock(block: TmplAstIfBlock) {
this.visitAll(block.branches);
}

visitIfBlockBranch(block: TmplAstIfBlockBranch) {
this.visitAll(block.children);
}

visitTemplate(template: TmplAstTemplate) {
this.visitAll(template.children);
}

visitUnknownBlock(block: TmplAstUnknownBlock) {}
visitLetDeclaration(decl: TmplAstLetDeclaration) {}

visitComponent(component: TmplAstComponent) {}
visitDirective(directive: TmplAstDirective) {}

visitAll(children: TmplAstNode[]) {
for (const child of children) {
this.visit(child);
}
}

private rangeIntersectsWith(span: ParseSourceSpan) {
const start = span.start.offset;
const length = span.end.offset - start;
return ts.textSpanIntersectsWith(this.range, start, length);
}

private classifyAs(type: TokenType, modifiers: number = 0) {
return ((type + 1) << TokenEncodingConsts.typeOffset) + modifiers;
}
}
19 changes: 19 additions & 0 deletions packages/language-service/src/ts_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,24 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService {
return ngLS.getRenameInfo(fileName, position);
}

function getEncodedSemanticClassifications(
fileName: string,
span: ts.TextSpan,
format?: ts.SemanticClassificationFormat,
): ts.Classifications {
if (angularOnly || !isTypeScriptFile(fileName)) {
return ngLS.getEncodedSemanticClassifications(fileName, span, format);
} else {
const ngClassifications = ngLS.getEncodedSemanticClassifications(fileName, span, format);
const tsClassifications = tsLS.getEncodedSemanticClassifications(fileName, span, format);
const spans = [...ngClassifications.spans, ...tsClassifications.spans];
return {
spans,
endOfLineState: tsClassifications.endOfLineState,
};
}
}

function getCompletionsAtPosition(
fileName: string,
position: number,
Expand Down Expand Up @@ -352,6 +370,7 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService {
getReferencesAtPosition,
findRenameLocations,
getRenameInfo,
getEncodedSemanticClassifications,
getCompletionsAtPosition,
getCompletionEntryDetails,
getCompletionEntrySymbol,
Expand Down
Loading
Loading
0