fix(language-service): honor quote style preference when generating imports

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)
This commit is contained in:
aparziale
2026-08-19 16:54:03 +02:00
committed by leonsenft
parent ba29223b38
commit 93d7f718d2
7 changed files with 129 additions and 16 deletions
@@ -95,6 +95,7 @@ function getCodeActions({typeCheckInfo, start, compiler, tsLs, preferences}: Cod
currMatch,
tsLs,
preferences.includeCompletionsForModuleExports,
preferences,
) ?? [];
codeActions.push(
@@ -865,6 +865,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
directive,
this.tsLS,
options?.includeCompletionsWithInsertText,
options,
)
: undefined;
@@ -1325,6 +1326,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
directive,
this.tsLS,
preferences?.includeCompletionsWithInsertText,
preferences,
)
: undefined;
@@ -375,6 +375,7 @@ export function updateImportsForTypescriptFile(
symbolName: string,
declarationName: string,
moduleSpecifier: string,
preferences?: ts.UserPreferences,
): [ts.TextChange[], string] {
// The trait might already be imported, possibly under a different name. If so, determine the
// local name of the imported trait.
@@ -421,11 +422,40 @@ export function updateImportsForTypescriptFile(
// TODO: Why does the compiler insist this is null?
span.start = lastImport!.getStart() + lastImport!.getWidth();
}
const newImportDeclaration = generateImport(symbolName, importName, moduleSpecifier);
const newImportDeclaration = generateImport(
symbolName,
importName,
moduleSpecifier,
shouldUseSingleQuotes(file, preferences),
);
const importString = '\n' + printNode(newImportDeclaration, file);
return [[{span, newText: importString}], importName];
}
/**
* Whether new string literals in the given file should use single quotes, based on the
* user's `quotePreference`. When the preference is `auto` (or absent), the style is inferred
* from the first existing import in the file, mirroring TypeScript's own behavior. Defaults
* to double quotes when there is nothing to infer from.
*/
export function shouldUseSingleQuotes(
file: ts.SourceFile,
preferences: ts.UserPreferences | undefined,
): boolean {
if (preferences?.quotePreference === 'single') {
return true;
}
if (preferences?.quotePreference === 'double') {
return false;
}
for (const statement of file.statements) {
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
return file.text[statement.moduleSpecifier.getStart(file)] === `'`;
}
}
return false;
}
/**
* Updates a given Angular trait, such as an NgModule or standalone Component, by adding
* `importName` to the list of imports on the decorator arguments.
@@ -590,13 +620,14 @@ export function generateImport(
localName: string,
exportedSpecifierName: string | null,
rawModuleSpecifier: string,
useSingleQuotes = false,
): ts.ImportDeclaration {
let propName: ts.Identifier | undefined;
if (exportedSpecifierName !== null && exportedSpecifierName !== localName) {
propName = ts.factory.createIdentifier(exportedSpecifierName);
}
const name = ts.factory.createIdentifier(localName);
const moduleSpec = ts.factory.createStringLiteral(rawModuleSpecifier);
const moduleSpec = ts.factory.createStringLiteral(rawModuleSpecifier, useSingleQuotes);
let importClauseName: ts.Identifier | undefined;
let importBindings: ts.NamedImportBindings | undefined;
@@ -692,6 +723,7 @@ export function getCodeActionToImportTheDirectiveDeclaration(
directive: PotentialDirective | PotentialPipe,
tsLs: ts.LanguageService,
includeCompletionsForModuleExports?: boolean,
preferences?: ts.UserPreferences,
): ts.CodeAction[] | undefined {
const codeActions: ts.CodeAction[] = [];
@@ -741,6 +773,7 @@ export function getCodeActionToImportTheDirectiveDeclaration(
potentialImport.symbolName,
declarationName,
potentialImport.moduleSpecifier,
preferences,
);
importName = generatedImportName;
fileImportChanges.push(...importChanges);
@@ -753,6 +786,7 @@ export function getCodeActionToImportTheDirectiveDeclaration(
'forwardRef',
declarationName,
'@angular/core',
preferences,
);
fileImportChanges.push(...forwardRefImports);
forwardRefName = generatedForwardRefName;
@@ -593,6 +593,80 @@ describe('code fixes', () => {
const fixFile = project.openFile('foo.ts');
fixFile.moveCursorToText('<¦bar>');
const codeActions = project.getCodeFixesAtPosition('foo.ts', fixFile.cursor, fixFile.cursor, [
diags[0].code,
]);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarComponent from './bar' on FooComponent`, [
[``, `import { BarComponent } from './bar';`],
[``, `, imports: [BarComponent]`],
]);
});
it('respects an explicit double quote preference for the generated import', () => {
const standaloneFiles = {
'foo.ts': `
import {Component} from '@angular/core';
@Component({
selector: 'foo',
template: '<bar></bar>'
})
export class FooComponent {}
`,
'bar.ts': `
import {Component} from '@angular/core';
@Component({
selector: 'bar',
template: '<div>bar</div>'
})
export class BarComponent {}
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', {}, {}, standaloneFiles);
const diags = project.getDiagnosticsForFile('foo.ts');
const fixFile = project.openFile('foo.ts');
fixFile.moveCursorToText('<¦bar>');
const codeActions = project.getCodeFixesAtPosition(
'foo.ts',
fixFile.cursor,
fixFile.cursor,
[diags[0].code],
{quotePreference: 'double'},
);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarComponent from './bar' on FooComponent`, [
[``, `import { BarComponent } from "./bar";`],
[``, `, imports: [BarComponent]`],
]);
});
it('matches the quote style of existing imports for the generated import', () => {
const standaloneFiles = {
'foo.ts': `
import {Component} from "@angular/core";
@Component({
selector: 'foo',
template: '<bar></bar>'
})
export class FooComponent {}
`,
'bar.ts': `
import {Component} from '@angular/core';
@Component({
selector: 'bar',
template: '<div>bar</div>'
})
export class BarComponent {}
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', {}, {}, standaloneFiles);
const diags = project.getDiagnosticsForFile('foo.ts');
const fixFile = project.openFile('foo.ts');
fixFile.moveCursorToText('<¦bar>');
const codeActions = project.getCodeFixesAtPosition('foo.ts', fixFile.cursor, fixFile.cursor, [
diags[0].code,
]);
@@ -640,7 +714,7 @@ describe('code fixes', () => {
]);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarModule from './bar' on FooComponent`, [
[``, `import { BarModule } from "./bar";`],
[``, `import { BarModule } from './bar';`],
[``, `, imports: [BarModule]`],
]);
});
@@ -682,7 +756,7 @@ describe('code fixes', () => {
]);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarComponent from './bar' on FooModule`, [
[``, `import { BarComponent } from "./bar";`],
[``, `import { BarComponent } from './bar';`],
[`imports: []`, `imports: [BarComponent]`],
]);
});
@@ -721,7 +795,7 @@ describe('code fixes', () => {
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarPipe from './bar' on FooComponent`, [
[``, `import { BarPipe } from "./bar";`],
[``, `import { BarPipe } from './bar';`],
['', `, imports: [BarPipe]`],
]);
});
@@ -768,11 +842,11 @@ describe('code fixes', () => {
]);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarModule from './bar' on FooComponent`, [
[``, `import { BarModule } from "./bar";`],
[``, `import { BarModule } from './bar';`],
[``, `, imports: [BarModule]`],
]);
actionChangesMatch(actionChanges, `Import Bar2Module from './bar' on FooComponent`, [
[``, `import { Bar2Module } from "./bar";`],
[``, `import { Bar2Module } from './bar';`],
[``, `, imports: [Bar2Module]`],
]);
});
@@ -808,7 +882,7 @@ describe('code fixes', () => {
]);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarComponent from './bar' on FooComponent`, [
[``, `import BarComponent from "./bar";`],
[``, `import BarComponent from './bar';`],
[``, `, imports: [BarComponent]`],
]);
});
@@ -948,7 +1022,7 @@ describe('code fixes', () => {
]);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import MatCard from '@angular/common' on FooComponent`, [
[``, `import { MatCard } from "@angular/common";`],
[``, `import { MatCard } from '@angular/common';`],
[``, `, imports: [MatCard]`],
]);
});
@@ -988,7 +1062,7 @@ describe('code fixes', () => {
);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarComponent from '@app/bar' on FooComponent`, [
[``, `import { BarComponent } from "@app/bar";`],
[``, `import { BarComponent } from '@app/bar';`],
[``, `, imports: [BarComponent]`],
]);
});
@@ -1041,7 +1115,7 @@ describe('code fixes', () => {
actionChanges,
`Import NewBarComponent3 from '@app/index' on FooComponent`,
[
[``, `import { NewBarComponent3 } from "@app/index";`],
[``, `import { NewBarComponent3 } from '@app/index';`],
[``, `, imports: [NewBarComponent3]`],
],
);
@@ -1197,7 +1271,7 @@ describe('code fixes', () => {
actionChanges,
`Import BarModule from '../component/share/bar.module' on FooComponent`,
[
[``, `import { BarModule } from "../component/share/bar.module";`],
[``, `import { BarModule } from '../component/share/bar.module';`],
[``, `, imports: [BarModule]`],
],
);
@@ -1255,7 +1329,7 @@ describe('code fixes', () => {
const actionChanges = allChangesForCodeActions(appModuleContents, codeActions);
actionChangesMatch(actionChanges, `Import BarComponent from './bar' on AppModule`, [
[``, `import { BarComponent } from "./bar";`],
[``, `import { BarComponent } from './bar';`],
[`imports: []`, `imports: [BarComponent]`],
]);
});
@@ -1599,7 +1599,7 @@ describe('completions', () => {
'start': 303,
'length': 0,
},
'newText': '\nimport { HighlightDirective } from "@app/highlight";',
'newText': "\nimport { HighlightDirective } from '@app/highlight';",
},
{
'span': {
@@ -1805,7 +1805,7 @@ describe('completions', () => {
'start': 303,
'length': 0,
},
'newText': '\nimport { HighlightDirective } from "@app/highlight";',
'newText': "\nimport { HighlightDirective } from '@app/highlight';",
},
{
'span': {
@@ -216,6 +216,7 @@ export class Project {
start: number,
end: number,
errorCodes: readonly number[],
preferences: ts.UserPreferences = {},
): readonly ts.CodeFixAction[] {
const fileName = absoluteFrom(`/${this.name}/${projectFileName}`);
return this.ngLS.getCodeFixesAtPosition(
@@ -226,6 +227,7 @@ export class Project {
{},
{
includeCompletionsForModuleExports: true,
...preferences,
},
);
}
@@ -2420,7 +2420,7 @@ export class AppComponent {
'changes': {
[APP_COMPONENT_MODULE_URI]: [
{
'newText': '\nimport { BarComponent } from "./bar.component";',
'newText': "\nimport { BarComponent } from './bar.component';",
'range': {
// Line numbers adjusted for HighlightDirective import
'start': {'line': 6, 'character': 57},