refactor(language-service): Return ts.Diagnostic[] for getDiagnostics (#32115)

Part 2/3 of language service refactoring:
Now that the language service is a proper tsserver plugin, all LS
interfaces should return TS values. This PR refactors the
ng.getDiagnostics() API to return ts.Diagnostic[] instead of
ng.Diagnostic[].

PR Close #32115
This commit is contained in:
Keen Yee Liau 2019-08-09 15:52:49 -07:00 committed by Kara Erickson
parent a91ab15525
commit a5f39aeda6
7 changed files with 117 additions and 111 deletions

View File

@ -7,13 +7,52 @@
*/ */
import {NgAnalyzedModules, StaticSymbol} from '@angular/compiler'; import {NgAnalyzedModules, StaticSymbol} from '@angular/compiler';
import {DiagnosticTemplateInfo, getTemplateExpressionDiagnostics} from '@angular/compiler-cli/src/language_services';
import * as ts from 'typescript';
import {AstResult} from './common'; import {AstResult} from './common';
import {Declarations, Diagnostic, DiagnosticKind, DiagnosticMessageChain, Diagnostics, Span, TemplateSource} from './types'; import {Declarations, Diagnostic, DiagnosticKind, DiagnosticMessageChain, Diagnostics, Span, TemplateSource} from './types';
import {offsetSpan, spanOf} from './utils';
export interface AstProvider { export interface AstProvider {
getTemplateAst(template: TemplateSource, fileName: string): AstResult; getTemplateAst(template: TemplateSource, fileName: string): AstResult;
} }
export function getTemplateDiagnostics(template: TemplateSource, ast: AstResult): Diagnostics {
const results: Diagnostics = [];
if (ast.parseErrors && ast.parseErrors.length) {
results.push(...ast.parseErrors.map<Diagnostic>(e => {
return {
kind: DiagnosticKind.Error,
span: offsetSpan(spanOf(e.span), template.span.start),
message: e.msg,
};
}));
} else if (ast.templateAst && ast.htmlAst) {
const info: DiagnosticTemplateInfo = {
templateAst: ast.templateAst,
htmlAst: ast.htmlAst,
offset: template.span.start,
query: template.query,
members: template.members,
};
const expressionDiagnostics = getTemplateExpressionDiagnostics(info);
results.push(...expressionDiagnostics);
}
if (ast.errors) {
results.push(...ast.errors.map<Diagnostic>(e => {
return {
kind: e.kind,
span: e.span || template.span,
message: e.message,
};
}));
}
return results;
}
export function getDeclarationDiagnostics( export function getDeclarationDiagnostics(
declarations: Declarations, modules: NgAnalyzedModules): Diagnostics { declarations: Declarations, modules: NgAnalyzedModules): Diagnostics {
const results: Diagnostics = []; const results: Diagnostics = [];
@ -60,3 +99,34 @@ export function getDeclarationDiagnostics(
return results; return results;
} }
function diagnosticChainToDiagnosticChain(chain: DiagnosticMessageChain):
ts.DiagnosticMessageChain {
return {
messageText: chain.message,
category: ts.DiagnosticCategory.Error,
code: 0,
next: chain.next ? diagnosticChainToDiagnosticChain(chain.next) : undefined
};
}
function diagnosticMessageToDiagnosticMessageText(message: string | DiagnosticMessageChain): string|
ts.DiagnosticMessageChain {
if (typeof message === 'string') {
return message;
}
return diagnosticChainToDiagnosticChain(message);
}
export function ngDiagnosticToTsDiagnostic(
d: Diagnostic, file: ts.SourceFile | undefined): ts.Diagnostic {
return {
file,
start: d.span.start,
length: d.span.end - d.span.start,
messageText: diagnosticMessageToDiagnosticMessageText(d.message),
category: ts.DiagnosticCategory.Error,
code: 0,
source: 'ng',
};
}

View File

@ -6,16 +6,15 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {CompileMetadataResolver, CompilePipeSummary} from '@angular/compiler'; import {CompilePipeSummary} from '@angular/compiler';
import {DiagnosticTemplateInfo, getTemplateExpressionDiagnostics} from '@angular/compiler-cli/src/language_services';
import * as tss from 'typescript/lib/tsserverlibrary'; import * as tss from 'typescript/lib/tsserverlibrary';
import {getTemplateCompletions} from './completions'; import {getTemplateCompletions} from './completions';
import {getDefinitionAndBoundSpan} from './definitions'; import {getDefinitionAndBoundSpan} from './definitions';
import {getDeclarationDiagnostics} from './diagnostics'; import {getDeclarationDiagnostics, getTemplateDiagnostics, ngDiagnosticToTsDiagnostic} from './diagnostics';
import {getHover} from './hover'; import {getHover} from './hover';
import {Completion, Diagnostic, DiagnosticKind, Diagnostics, Hover, LanguageService, LanguageServiceHost, Location, Span, TemplateSource} from './types'; import {Completion, Diagnostic, LanguageService, Span} from './types';
import {offsetSpan, spanOf} from './utils'; import {TypeScriptServiceHost} from './typescript_host';
/** /**
@ -23,20 +22,21 @@ import {offsetSpan, spanOf} from './utils';
* *
* @publicApi * @publicApi
*/ */
export function createLanguageService(host: LanguageServiceHost): LanguageService { export function createLanguageService(host: TypeScriptServiceHost): LanguageService {
return new LanguageServiceImpl(host); return new LanguageServiceImpl(host);
} }
class LanguageServiceImpl implements LanguageService { class LanguageServiceImpl implements LanguageService {
constructor(private host: LanguageServiceHost) {} constructor(private readonly host: TypeScriptServiceHost) {}
getTemplateReferences(): string[] { return this.host.getTemplateReferences(); } getTemplateReferences(): string[] { return this.host.getTemplateReferences(); }
getDiagnostics(fileName: string): Diagnostic[] { getDiagnostics(fileName: string): tss.Diagnostic[] {
const results: Diagnostic[] = []; const results: Diagnostic[] = [];
const templates = this.host.getTemplates(fileName); const templates = this.host.getTemplates(fileName);
if (templates && templates.length) { for (const template of templates) {
results.push(...this.getTemplateDiagnostics(fileName, templates)); const ast = this.host.getTemplateAst(template, fileName);
results.push(...getTemplateDiagnostics(template, ast));
} }
const declarations = this.host.getDeclarations(fileName); const declarations = this.host.getDeclarations(fileName);
@ -44,8 +44,11 @@ class LanguageServiceImpl implements LanguageService {
const summary = this.host.getAnalyzedModules(); const summary = this.host.getAnalyzedModules();
results.push(...getDeclarationDiagnostics(declarations, summary)); results.push(...getDeclarationDiagnostics(declarations, summary));
} }
if (!results.length) {
return uniqueBySpan(results); return [];
}
const sourceFile = fileName.endsWith('.ts') ? this.host.getSourceFile(fileName) : undefined;
return uniqueBySpan(results).map(d => ngDiagnosticToTsDiagnostic(d, sourceFile));
} }
getPipesAt(fileName: string, position: number): CompilePipeSummary[] { getPipesAt(fileName: string, position: number): CompilePipeSummary[] {
@ -76,38 +79,6 @@ class LanguageServiceImpl implements LanguageService {
return getHover(templateInfo); return getHover(templateInfo);
} }
} }
private getTemplateDiagnostics(fileName: string, templates: TemplateSource[]): Diagnostics {
const results: Diagnostics = [];
for (const template of templates) {
const ast = this.host.getTemplateAst(template, fileName);
if (ast) {
if (ast.parseErrors && ast.parseErrors.length) {
results.push(...ast.parseErrors.map<Diagnostic>(
e => ({
kind: DiagnosticKind.Error,
span: offsetSpan(spanOf(e.span), template.span.start),
message: e.msg
})));
} else if (ast.templateAst && ast.htmlAst) {
const info: DiagnosticTemplateInfo = {
templateAst: ast.templateAst,
htmlAst: ast.htmlAst,
offset: template.span.start,
query: template.query,
members: template.members
};
const expressionDiagnostics = getTemplateExpressionDiagnostics(info);
results.push(...expressionDiagnostics);
}
if (ast.errors) {
results.push(...ast.errors.map<Diagnostic>(
e => ({kind: e.kind, span: e.span || template.span, message: e.message})));
}
}
}
return results;
}
} }
function uniqueBySpan<T extends{span: Span}>(elements: T[]): T[] { function uniqueBySpan<T extends{span: Span}>(elements: T[]): T[] {

View File

@ -10,7 +10,7 @@ import * as ts from 'typescript'; // used as value, passed in by tsserver at run
import * as tss from 'typescript/lib/tsserverlibrary'; // used as type only import * as tss from 'typescript/lib/tsserverlibrary'; // used as type only
import {createLanguageService} from './language_service'; import {createLanguageService} from './language_service';
import {Completion, Diagnostic, DiagnosticMessageChain} from './types'; import {Completion} from './types';
import {TypeScriptServiceHost} from './typescript_host'; import {TypeScriptServiceHost} from './typescript_host';
const projectHostMap = new WeakMap<tss.server.Project, TypeScriptServiceHost>(); const projectHostMap = new WeakMap<tss.server.Project, TypeScriptServiceHost>();
@ -33,36 +33,6 @@ function completionToEntry(c: Completion): tss.CompletionEntry {
}; };
} }
function diagnosticChainToDiagnosticChain(chain: DiagnosticMessageChain):
ts.DiagnosticMessageChain {
return {
messageText: chain.message,
category: ts.DiagnosticCategory.Error,
code: 0,
next: chain.next ? diagnosticChainToDiagnosticChain(chain.next) : undefined
};
}
function diagnosticMessageToDiagnosticMessageText(message: string | DiagnosticMessageChain): string|
tss.DiagnosticMessageChain {
if (typeof message === 'string') {
return message;
}
return diagnosticChainToDiagnosticChain(message);
}
function diagnosticToDiagnostic(d: Diagnostic, file: tss.SourceFile | undefined): tss.Diagnostic {
return {
file,
start: d.span.start,
length: d.span.end - d.span.start,
messageText: diagnosticMessageToDiagnosticMessageText(d.message),
category: ts.DiagnosticCategory.Error,
code: 0,
source: 'ng'
};
}
export function create(info: tss.server.PluginCreateInfo): tss.LanguageService { export function create(info: tss.server.PluginCreateInfo): tss.LanguageService {
const {project, languageService: tsLS, languageServiceHost: tsLSHost, config} = info; const {project, languageService: tsLS, languageServiceHost: tsLSHost, config} = info;
// This plugin could operate under two different modes: // This plugin could operate under two different modes:
@ -115,16 +85,10 @@ export function create(info: tss.server.PluginCreateInfo): tss.LanguageService {
function getSemanticDiagnostics(fileName: string): tss.Diagnostic[] { function getSemanticDiagnostics(fileName: string): tss.Diagnostic[] {
const results: tss.Diagnostic[] = []; const results: tss.Diagnostic[] = [];
if (!angularOnly) { if (!angularOnly) {
const tsResults = tsLS.getSemanticDiagnostics(fileName); results.push(...tsLS.getSemanticDiagnostics(fileName));
results.push(...tsResults);
} }
// For semantic diagnostics we need to combine both TS + Angular results // For semantic diagnostics we need to combine both TS + Angular results
const ngResults = ngLS.getDiagnostics(fileName); results.push(...ngLS.getDiagnostics(fileName));
if (!ngResults.length) {
return results;
}
const sourceFile = fileName.endsWith('.ts') ? ngLSHost.getSourceFile(fileName) : undefined;
results.push(...ngResults.map(d => diagnosticToDiagnostic(d, sourceFile)));
return results; return results;
} }

View File

@ -190,7 +190,7 @@ export interface LanguageServiceHost {
* Return the template source information for all templates in `fileName` or for `fileName` if * Return the template source information for all templates in `fileName` or for `fileName` if
* it is a template file. * it is a template file.
*/ */
getTemplates(fileName: string): TemplateSources; getTemplates(fileName: string): TemplateSource[];
/** /**
* Returns the Angular declarations in the given file. * Returns the Angular declarations in the given file.
@ -386,7 +386,7 @@ export interface LanguageService {
/** /**
* Returns a list of all error for all templates in the given file. * Returns a list of all error for all templates in the given file.
*/ */
getDiagnostics(fileName: string): Diagnostic[]; getDiagnostics(fileName: string): tss.Diagnostic[];
/** /**
* Return the completions at the given position. * Return the completions at the given position.

View File

@ -166,16 +166,16 @@ export class TypeScriptServiceHost implements LanguageServiceHost {
return analyzedModules; return analyzedModules;
} }
getTemplates(fileName: string): TemplateSources { getTemplates(fileName: string): TemplateSource[] {
const results: TemplateSource[] = [];
if (fileName.endsWith('.ts')) { if (fileName.endsWith('.ts')) {
let version = this.host.getScriptVersion(fileName); let version = this.host.getScriptVersion(fileName);
let result: TemplateSource[] = [];
// Find each template string in the file // Find each template string in the file
let visit = (child: ts.Node) => { let visit = (child: ts.Node) => {
let templateSource = this.getSourceFromNode(fileName, version, child); let templateSource = this.getSourceFromNode(fileName, version, child);
if (templateSource) { if (templateSource) {
result.push(templateSource); results.push(templateSource);
} else { } else {
ts.forEachChild(child, visit); ts.forEachChild(child, visit);
} }
@ -185,17 +185,17 @@ export class TypeScriptServiceHost implements LanguageServiceHost {
if (sourceFile) { if (sourceFile) {
ts.forEachChild(sourceFile, visit); ts.forEachChild(sourceFile, visit);
} }
return result.length ? result : undefined;
} else { } else {
this.ensureTemplateMap(); this.ensureTemplateMap();
const componentSymbol = this.fileToComponent.get(fileName); const componentSymbol = this.fileToComponent.get(fileName);
if (componentSymbol) { if (componentSymbol) {
const templateSource = this.getTemplateAt(fileName, 0); const templateSource = this.getTemplateAt(fileName, 0);
if (templateSource) { if (templateSource) {
return [templateSource]; results.push(templateSource);
} }
} }
} }
return results;
} }
getDeclarations(fileName: string): Declarations { getDeclarations(fileName: string): Declarations {

View File

@ -34,7 +34,7 @@ describe('diagnostics', () => {
describe('for semantic errors', () => { describe('for semantic errors', () => {
const fileName = '/app/test.ng'; const fileName = '/app/test.ng';
function diagnostics(template: string): Diagnostics { function diagnostics(template: string): ts.Diagnostic[] {
try { try {
mockHost.override(fileName, template); mockHost.override(fileName, template);
return ngService.getDiagnostics(fileName) !; return ngService.getDiagnostics(fileName) !;
@ -185,7 +185,7 @@ describe('diagnostics', () => {
addCode(code, fileName => { addCode(code, fileName => {
const diagnostic = findDiagnostic(ngService.getDiagnostics(fileName) !, 'pipe') !; const diagnostic = findDiagnostic(ngService.getDiagnostics(fileName) !, 'pipe') !;
expect(diagnostic).not.toBeUndefined(); expect(diagnostic).not.toBeUndefined();
expect(diagnostic.span.end - diagnostic.span.start).toBeLessThan(11); expect(diagnostic.length).toBeLessThan(11);
}); });
}); });
@ -379,13 +379,13 @@ describe('diagnostics', () => {
} }
} }
function expectOnlyModuleDiagnostics(diagnostics: Diagnostics | undefined) { function expectOnlyModuleDiagnostics(diagnostics: ts.Diagnostic[] | undefined) {
// Expect only the 'MyComponent' diagnostic // Expect only the 'MyComponent' diagnostic
if (!diagnostics) throw new Error('Expecting Diagnostics'); if (!diagnostics) throw new Error('Expecting Diagnostics');
if (diagnostics.length > 1) { if (diagnostics.length > 1) {
const unexpectedDiagnostics = const unexpectedDiagnostics =
diagnostics.filter(diag => !diagnosticMessageContains(diag.message, 'MyComponent')) diagnostics.filter(diag => !diagnosticMessageContains(diag.messageText, 'MyComponent'))
.map(diag => `(${diag.span.start}:${diag.span.end}): ${diag.message}`); .map(diag => `(${diag.start}:${diag.start! + diag.length!}): ${diag.messageText}`);
if (unexpectedDiagnostics.length) { if (unexpectedDiagnostics.length) {
fail(`Unexpected diagnostics:\n ${unexpectedDiagnostics.join('\n ')}`); fail(`Unexpected diagnostics:\n ${unexpectedDiagnostics.join('\n ')}`);
@ -393,7 +393,7 @@ describe('diagnostics', () => {
} }
} }
expect(diagnostics.length).toBe(1); expect(diagnostics.length).toBe(1);
expect(diagnosticMessageContains(diagnostics[0].message, 'MyComponent')).toBeTruthy(); expect(diagnosticMessageContains(diagnostics[0].messageText, 'MyComponent')).toBeTruthy();
} }
}); });
}); });

View File

@ -331,18 +331,19 @@ function removeReferenceMarkers(value: string): string {
return value.replace(referenceMarker, (match, text) => text.replace(/ᐱ/g, '')); return value.replace(referenceMarker, (match, text) => text.replace(/ᐱ/g, ''));
} }
export function noDiagnostics(diagnostics: Diagnostics) { export function noDiagnostics(diagnostics: ts.Diagnostic[]) {
if (diagnostics && diagnostics.length) { if (diagnostics && diagnostics.length) {
throw new Error(`Unexpected diagnostics: \n ${diagnostics.map(d => d.message).join('\n ')}`); throw new Error(
`Unexpected diagnostics: \n ${diagnostics.map(d => d.messageText).join('\n ')}`);
} }
} }
export function diagnosticMessageContains( export function diagnosticMessageContains(
message: string | DiagnosticMessageChain, messageFragment: string): boolean { message: string | ts.DiagnosticMessageChain, messageFragment: string): boolean {
if (typeof message == 'string') { if (typeof message == 'string') {
return message.indexOf(messageFragment) >= 0; return message.indexOf(messageFragment) >= 0;
} }
if (message.message.indexOf(messageFragment) >= 0) { if (message.messageText.indexOf(messageFragment) >= 0) {
return true; return true;
} }
if (message.next) { if (message.next) {
@ -351,16 +352,17 @@ export function diagnosticMessageContains(
return false; return false;
} }
export function findDiagnostic(diagnostics: Diagnostic[], messageFragment: string): Diagnostic| export function findDiagnostic(
undefined { diagnostics: ts.Diagnostic[], messageFragment: string): ts.Diagnostic|undefined {
return diagnostics.find(d => diagnosticMessageContains(d.message, messageFragment)); return diagnostics.find(d => diagnosticMessageContains(d.messageText, messageFragment));
} }
export function includeDiagnostic( export function includeDiagnostic(
diagnostics: Diagnostics, message: string, text?: string, len?: string): void; diagnostics: ts.Diagnostic[], message: string, text?: string, len?: string): void;
export function includeDiagnostic( export function includeDiagnostic(
diagnostics: Diagnostics, message: string, at?: number, len?: number): void; diagnostics: ts.Diagnostic[], message: string, at?: number, len?: number): void;
export function includeDiagnostic(diagnostics: Diagnostics, message: string, p1?: any, p2?: any) { export function includeDiagnostic(
diagnostics: ts.Diagnostic[], message: string, p1?: any, p2?: any) {
expect(diagnostics).toBeDefined(); expect(diagnostics).toBeDefined();
if (diagnostics) { if (diagnostics) {
const diagnostic = findDiagnostic(diagnostics, message); const diagnostic = findDiagnostic(diagnostics, message);
@ -368,13 +370,12 @@ export function includeDiagnostic(diagnostics: Diagnostics, message: string, p1?
if (diagnostic && p1 != null) { if (diagnostic && p1 != null) {
const at = typeof p1 === 'number' ? p1 : p2.indexOf(p1); const at = typeof p1 === 'number' ? p1 : p2.indexOf(p1);
const len = typeof p2 === 'number' ? p2 : p1.length; const len = typeof p2 === 'number' ? p2 : p1.length;
expect(diagnostic.span.start) expect(diagnostic.start)
.toEqual( .toEqual(
at, at,
`expected message '${message}' was reported at ${diagnostic.span.start} but should be ${at}`); `expected message '${message}' was reported at ${diagnostic.start} but should be ${at}`);
if (len != null) { if (len != null) {
expect(diagnostic.span.end - diagnostic.span.start) expect(diagnostic.length).toEqual(len, `expected '${message}'s span length to be ${len}`);
.toEqual(len, `expected '${message}'s span length to be ${len}`);
} }
} }
} }