mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
93d7f718d2
The quick fix and completion auto-import always generated the module specifier with double quotes, ignoring the user's quote preference and the style used in the file.
The generated import now respects the `quotePreference` from the TypeScript user preferences. When the preference is `auto` (or absent), the style is inferred from the first existing import in the file, mirroring TypeScript's own behavior. Files with no imports
keep the previous double-quote default.
Fixes #67108
(cherry picked from commit 0f0d52e9c1)
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
/**
|
|
* @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 {InternalOptions, TypeCheckingOptions} from '@angular/compiler-cli/src/ngtsc/core/api';
|
|
import {
|
|
absoluteFrom,
|
|
AbsoluteFsPath,
|
|
FileSystem,
|
|
getFileSystem,
|
|
getSourceFileOrError,
|
|
} from '@angular/compiler-cli/src/ngtsc/file_system';
|
|
import {OptimizeFor, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
|
import ts from 'typescript';
|
|
|
|
import {ApplyRefactoringProgressFn, ApplyRefactoringResult} from '../../api';
|
|
import {LanguageService} from '../../src/language_service';
|
|
|
|
import {OpenBuffer} from './buffer';
|
|
import {patchLanguageServiceProjectsWithTestHost} from './language_service_test_cache';
|
|
|
|
export type ProjectFiles = {
|
|
[fileName: string]: string;
|
|
};
|
|
|
|
function writeTsconfig(
|
|
fs: FileSystem,
|
|
tsConfigPath: AbsoluteFsPath,
|
|
entryFiles: AbsoluteFsPath[],
|
|
angularCompilerOptions: TestableOptions,
|
|
tsCompilerOptions: any = {},
|
|
): void {
|
|
const basePaths = {
|
|
'@angular/core': ['./node_modules/@angular/core'],
|
|
'@angular/core/rxjs-interop': ['./node_modules/@angular/core/rxjs-interop'],
|
|
'@angular/forms': ['./node_modules/@angular/forms'],
|
|
'@angular/animations': ['./node_modules/@angular/animations'],
|
|
'@angular/common': ['./node_modules/@angular/common'],
|
|
rxjs: ['./node_modules/rxjs'],
|
|
'rxjs/operators': ['./node_modules/rxjs/operators'],
|
|
};
|
|
|
|
const mergedPaths = {
|
|
...basePaths,
|
|
...(tsCompilerOptions.paths || {}),
|
|
};
|
|
|
|
fs.writeFile(
|
|
tsConfigPath,
|
|
JSON.stringify(
|
|
{
|
|
compilerOptions: {
|
|
strict: true,
|
|
experimentalDecorators: true,
|
|
moduleResolution: 'bundler',
|
|
target: 'es2015',
|
|
rootDir: '.',
|
|
lib: ['dom', 'es2015'],
|
|
...tsCompilerOptions,
|
|
paths: mergedPaths,
|
|
},
|
|
files: entryFiles,
|
|
angularCompilerOptions: {
|
|
strictTemplates: true,
|
|
...angularCompilerOptions,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|
|
|
|
export type TestableOptions = TypeCheckingOptions &
|
|
InternalOptions & {
|
|
// This already exists in `InternalOptions`, but it's `internal` so it's stripped away.
|
|
_enableSelectorless?: boolean;
|
|
};
|
|
|
|
export class Project {
|
|
private tsProject: ts.server.Project;
|
|
private tsLS: ts.LanguageService;
|
|
readonly ngLS: LanguageService;
|
|
private buffers = new Map<string, OpenBuffer>();
|
|
|
|
static initialize(
|
|
projectName: string,
|
|
projectService: ts.server.ProjectService,
|
|
files: ProjectFiles,
|
|
angularCompilerOptions: TestableOptions = {},
|
|
tsCompilerOptions = {},
|
|
): Project {
|
|
const fs = getFileSystem();
|
|
const tsConfigPath = absoluteFrom(`/${projectName}/tsconfig.json`);
|
|
|
|
const entryFiles: AbsoluteFsPath[] = [];
|
|
for (const projectFilePath of Object.keys(files)) {
|
|
const contents = files[projectFilePath];
|
|
const filePath = absoluteFrom(`/${projectName}/${projectFilePath}`);
|
|
const dirPath = fs.dirname(filePath);
|
|
fs.ensureDir(dirPath);
|
|
fs.writeFile(filePath, contents);
|
|
if (projectFilePath.endsWith('.ts') && !projectFilePath.endsWith('.d.ts')) {
|
|
entryFiles.push(filePath);
|
|
}
|
|
|
|
// Force TypeScript to load the new file content immediately
|
|
const scriptInfo = projectService.getOrCreateScriptInfoForNormalizedPath(
|
|
ts.server.toNormalizedPath(filePath),
|
|
true, // openedByClient
|
|
contents,
|
|
ts.ScriptKind.Unknown, // infer
|
|
false, // hasMixedContent
|
|
projectService.host,
|
|
);
|
|
if (scriptInfo) {
|
|
scriptInfo.reloadFromFile();
|
|
}
|
|
}
|
|
|
|
writeTsconfig(fs, tsConfigPath, entryFiles, angularCompilerOptions, tsCompilerOptions);
|
|
|
|
const tsConfigInfo = projectService.getScriptInfo(tsConfigPath);
|
|
if (tsConfigInfo) {
|
|
tsConfigInfo.reloadFromFile();
|
|
}
|
|
|
|
patchLanguageServiceProjectsWithTestHost();
|
|
|
|
projectService.openClientFile(entryFiles[0]);
|
|
projectService.closeClientFile(entryFiles[0]);
|
|
|
|
return new Project(projectName, projectService, tsConfigPath);
|
|
}
|
|
|
|
private constructor(
|
|
readonly name: string,
|
|
private projectService: ts.server.ProjectService,
|
|
private tsConfigPath: AbsoluteFsPath,
|
|
) {
|
|
const tsProject = projectService.findProject(tsConfigPath);
|
|
if (tsProject === undefined) {
|
|
throw new Error(`Failed to create project for ${tsConfigPath}`);
|
|
}
|
|
|
|
this.tsProject = tsProject;
|
|
this.tsLS = tsProject.getLanguageService();
|
|
this.ngLS = new LanguageService(tsProject, this.tsLS, {});
|
|
}
|
|
|
|
getAbsFileName(projectFileName: string): string {
|
|
return absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
}
|
|
|
|
openFile(projectFileName: string): OpenBuffer {
|
|
if (!this.buffers.has(projectFileName)) {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
let scriptInfo = this.tsProject.getScriptInfo(fileName);
|
|
this.projectService.openClientFile(
|
|
// By attempting to open the file, the compiler is going to try to parse it as
|
|
// TS which will throw an error. We pass in JSX which is more permissive.
|
|
fileName,
|
|
undefined,
|
|
fileName.endsWith('.html') ? ts.ScriptKind.JSX : ts.ScriptKind.TS,
|
|
);
|
|
|
|
scriptInfo = this.tsProject.getScriptInfo(fileName);
|
|
if (scriptInfo === undefined) {
|
|
throw new Error(
|
|
`Unable to open ScriptInfo for ${projectFileName} in project ${this.tsConfigPath}`,
|
|
);
|
|
}
|
|
this.buffers.set(
|
|
projectFileName,
|
|
new OpenBuffer(this.ngLS, this.tsProject, projectFileName, scriptInfo),
|
|
);
|
|
}
|
|
|
|
return this.buffers.get(projectFileName)!;
|
|
}
|
|
|
|
getSourceFile(projectFileName: string): ts.SourceFile | undefined {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
return this.tsProject.getSourceFile(this.projectService.toPath(fileName));
|
|
}
|
|
|
|
getTypeChecker(): ts.TypeChecker {
|
|
return this.ngLS.compilerFactory.getOrCreate().getCurrentProgram().getTypeChecker();
|
|
}
|
|
|
|
getDiagnosticsForFile(projectFileName: string): ts.Diagnostic[] {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
const diagnostics: ts.Diagnostic[] = [];
|
|
if (fileName.endsWith('.ts')) {
|
|
diagnostics.push(...this.tsLS.getSyntacticDiagnostics(fileName));
|
|
diagnostics.push(...this.tsLS.getSemanticDiagnostics(fileName));
|
|
}
|
|
|
|
diagnostics.push(...this.ngLS.getSemanticDiagnostics(fileName));
|
|
return diagnostics;
|
|
}
|
|
|
|
getSuggestionDiagnosticsForFile(projectFileName: string): ts.Diagnostic[] {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
const diagnostics: ts.Diagnostic[] = [];
|
|
diagnostics.push(...this.ngLS.getSuggestionDiagnostics(fileName));
|
|
return diagnostics;
|
|
}
|
|
|
|
getCodeFixesAtPosition(
|
|
projectFileName: string,
|
|
start: number,
|
|
end: number,
|
|
errorCodes: readonly number[],
|
|
preferences: ts.UserPreferences = {},
|
|
): readonly ts.CodeFixAction[] {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
return this.ngLS.getCodeFixesAtPosition(
|
|
fileName,
|
|
start,
|
|
end,
|
|
errorCodes,
|
|
{},
|
|
{
|
|
includeCompletionsForModuleExports: true,
|
|
...preferences,
|
|
},
|
|
);
|
|
}
|
|
|
|
getRefactoringsAtPosition(
|
|
projectFileName: string,
|
|
positionOrRange: number | ts.TextRange,
|
|
): readonly ts.ApplicableRefactorInfo[] {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
return this.ngLS.getPossibleRefactorings(fileName, positionOrRange);
|
|
}
|
|
|
|
applyRefactoring(
|
|
projectFileName: string,
|
|
positionOrRange: number | ts.TextRange,
|
|
refactorName: string,
|
|
reportProgress: ApplyRefactoringProgressFn,
|
|
): Promise<ApplyRefactoringResult | undefined> {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
return this.ngLS.applyRefactoring(fileName, positionOrRange, refactorName, reportProgress);
|
|
}
|
|
|
|
getCombinedCodeFix(projectFileName: string, fixId: string): ts.CombinedCodeActions {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
return this.ngLS.getCombinedCodeFix(
|
|
{
|
|
type: 'file',
|
|
fileName,
|
|
},
|
|
fixId,
|
|
{},
|
|
{},
|
|
);
|
|
}
|
|
|
|
expectNoSourceDiagnostics(): void {
|
|
const program = this.tsLS.getProgram();
|
|
if (program === undefined) {
|
|
throw new Error(`Expected to get a ts.Program`);
|
|
}
|
|
|
|
const ngCompiler = this.ngLS.compilerFactory.getOrCreate();
|
|
|
|
for (const sf of program.getSourceFiles()) {
|
|
if (
|
|
sf.isDeclarationFile ||
|
|
sf.fileName.endsWith('.ngtypecheck.ts') ||
|
|
!sf.fileName.endsWith('.ts')
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const syntactic = program.getSyntacticDiagnostics(sf);
|
|
expect(syntactic.map((diag) => diag.messageText)).toEqual([]);
|
|
if (syntactic.length > 0) {
|
|
continue;
|
|
}
|
|
|
|
const semantic = program.getSemanticDiagnostics(sf);
|
|
expect(semantic.map((diag) => diag.messageText)).toEqual([]);
|
|
if (semantic.length > 0) {
|
|
continue;
|
|
}
|
|
|
|
// It's more efficient to optimize for WholeProgram since we call this with every file in the
|
|
// program.
|
|
const ngDiagnostics = ngCompiler.getDiagnosticsForFile(sf, OptimizeFor.WholeProgram);
|
|
expect(ngDiagnostics.map((diag) => diag.messageText)).toEqual([]);
|
|
}
|
|
}
|
|
|
|
expectNoTemplateDiagnostics(projectFileName: string, className: string): void {
|
|
const program = this.tsLS.getProgram();
|
|
if (program === undefined) {
|
|
throw new Error(`Expected to get a ts.Program`);
|
|
}
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
const sf = getSourceFileOrError(program, fileName);
|
|
const component = getClassOrError(sf, className);
|
|
|
|
const diags = this.getTemplateTypeChecker().getDiagnosticsForComponent(component);
|
|
expect(diags.map((diag) => diag.messageText)).toEqual([]);
|
|
}
|
|
|
|
getTemplateTypeChecker(): TemplateTypeChecker {
|
|
return this.ngLS.compilerFactory.getOrCreate().getTemplateTypeChecker();
|
|
}
|
|
|
|
getLogger(): ts.server.Logger {
|
|
return this.tsProject.projectService.logger;
|
|
}
|
|
|
|
getLinkedEditingRangeAtPosition(
|
|
projectFileName: string,
|
|
position: number,
|
|
): {ranges: ts.TextSpan[]; wordPattern?: string} | null {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
return this.ngLS.getLinkedEditingRangeAtPosition(fileName, position) ?? null;
|
|
}
|
|
|
|
getSemanticDiagnostics(projectFileName: string): ts.Diagnostic[] {
|
|
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
|
|
return [...this.ngLS.getSemanticDiagnostics(fileName)];
|
|
}
|
|
}
|
|
|
|
function getClassOrError(sf: ts.SourceFile, name: string): ts.ClassDeclaration {
|
|
for (const stmt of sf.statements) {
|
|
if (ts.isClassDeclaration(stmt) && stmt.name !== undefined && stmt.name.text === name) {
|
|
return stmt;
|
|
}
|
|
}
|
|
throw new Error(`Class ${name} not found in file: ${sf.fileName}: ${sf.text}`);
|
|
}
|