diff --git a/packages/language-service/src/codefixes/fix_missing_import.ts b/packages/language-service/src/codefixes/fix_missing_import.ts index 09d34712732..a03cbb8a7f2 100644 --- a/packages/language-service/src/codefixes/fix_missing_import.ts +++ b/packages/language-service/src/codefixes/fix_missing_import.ts @@ -95,6 +95,7 @@ function getCodeActions({typeCheckInfo, start, compiler, tsLs, preferences}: Cod currMatch, tsLs, preferences.includeCompletionsForModuleExports, + preferences, ) ?? []; codeActions.push( diff --git a/packages/language-service/src/completions.ts b/packages/language-service/src/completions.ts index 2d8a4a4f23e..ca85b0f06cb 100644 --- a/packages/language-service/src/completions.ts +++ b/packages/language-service/src/completions.ts @@ -865,6 +865,7 @@ export class CompletionBuilder { directive, this.tsLS, options?.includeCompletionsWithInsertText, + options, ) : undefined; @@ -1325,6 +1326,7 @@ export class CompletionBuilder { directive, this.tsLS, preferences?.includeCompletionsWithInsertText, + preferences, ) : undefined; diff --git a/packages/language-service/src/utils/ts_utils.ts b/packages/language-service/src/utils/ts_utils.ts index cb9052ab102..d45f8fda578 100644 --- a/packages/language-service/src/utils/ts_utils.ts +++ b/packages/language-service/src/utils/ts_utils.ts @@ -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; diff --git a/packages/language-service/test/code_fixes_spec.ts b/packages/language-service/test/code_fixes_spec.ts index cdc420eac2b..359e7236382 100644 --- a/packages/language-service/test/code_fixes_spec.ts +++ b/packages/language-service/test/code_fixes_spec.ts @@ -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: '' + }) + export class FooComponent {} + `, + 'bar.ts': ` + import {Component} from '@angular/core'; + @Component({ + selector: 'bar', + template: '
bar
' + }) + 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: '' + }) + export class FooComponent {} + `, + 'bar.ts': ` + import {Component} from '@angular/core'; + @Component({ + selector: 'bar', + template: '
bar
' + }) + 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]`], ]); }); diff --git a/packages/language-service/test/completions_spec.ts b/packages/language-service/test/completions_spec.ts index 311872529ed..fed98546eaa 100644 --- a/packages/language-service/test/completions_spec.ts +++ b/packages/language-service/test/completions_spec.ts @@ -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': { diff --git a/packages/language-service/testing/src/project.ts b/packages/language-service/testing/src/project.ts index e4635659b67..05310763f6a 100644 --- a/packages/language-service/testing/src/project.ts +++ b/packages/language-service/testing/src/project.ts @@ -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, }, ); } diff --git a/vscode-ng-language-service/integration/lsp/ivy_spec.ts b/vscode-ng-language-service/integration/lsp/ivy_spec.ts index a8f9e614a5a..70f0bc5ecff 100644 --- a/vscode-ng-language-service/integration/lsp/ivy_spec.ts +++ b/vscode-ng-language-service/integration/lsp/ivy_spec.ts @@ -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},