Compare commits
39 commits
main
...
exportsinc
Author | SHA1 | Date | |
---|---|---|---|
|
1645750300 | ||
|
474653dfcc | ||
|
71c7c3f46f | ||
|
211e3f92bb | ||
|
49bb2b8d8b | ||
|
a41f3dfff6 | ||
|
f0c983a605 | ||
|
d1bdc25a9a | ||
|
bc14bb0fe4 | ||
|
e68c951930 | ||
|
72dd99fec9 | ||
|
b11f6e8f3e | ||
|
2ea36a603c | ||
|
8e5febb4c6 | ||
|
13a47e2405 | ||
|
fa33d5016b | ||
|
de7b821a82 | ||
|
e912a76682 | ||
|
e7d966b2e6 | ||
|
2875c15bbd | ||
|
8d5e075394 | ||
|
22c3373aee | ||
|
380b2994b2 | ||
|
95a9c01ca8 | ||
|
ae0ab477f7 | ||
|
abe1fdb0ea | ||
|
041302fa1d | ||
|
b024285c23 | ||
|
c5cc2f148c | ||
|
c838093e98 | ||
|
9940c9261a | ||
|
25831a8261 | ||
|
0aa865f6cc | ||
|
8a1a124856 | ||
|
15b73d09c3 | ||
|
a84b5b58ee | ||
|
087de799d2 | ||
|
5bef866249 | ||
|
d99675bb74 |
|
@ -6251,11 +6251,13 @@ namespace ts {
|
|||
|
||||
function symbolsToArray(symbols: SymbolTable): Symbol[] {
|
||||
const result: Symbol[] = [];
|
||||
if (symbols) {
|
||||
symbols.forEach((symbol, id) => {
|
||||
if (!isReservedMemberName(id)) {
|
||||
result.push(symbol);
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
@ -3788,6 +3788,43 @@ namespace ts.projectSystem {
|
|||
});
|
||||
});
|
||||
|
||||
describe("import in completion list", () => {
|
||||
it("should include exported members of all source files", () => {
|
||||
const file1: FileOrFolder = {
|
||||
path: "/a/b/file1.ts",
|
||||
content: `
|
||||
export function Test1() { }
|
||||
export function Test2() { }
|
||||
`
|
||||
};
|
||||
const file2: FileOrFolder = {
|
||||
path: "/a/b/file2.ts",
|
||||
content: `
|
||||
import { Test2 } from "./file1";
|
||||
|
||||
t`
|
||||
};
|
||||
const configFile: FileOrFolder = {
|
||||
path: "/a/b/tsconfig.json",
|
||||
content: "{}"
|
||||
};
|
||||
|
||||
const host = createServerHost([file1, file2, configFile]);
|
||||
const service = createProjectService(host);
|
||||
service.openClientFile(file2.path);
|
||||
|
||||
const completions1 = service.configuredProjects[0].getLanguageService().getCompletionsAtPosition(file2.path, file2.content.length);
|
||||
const test1Entry = find(completions1.entries, e => e.name === "Test1");
|
||||
const test2Entry = find(completions1.entries, e => e.name === "Test2");
|
||||
|
||||
assert.isDefined(test1Entry, "should contain 'Test1'");
|
||||
assert.isDefined(test2Entry, "should contain 'Test2'");
|
||||
|
||||
assert.isTrue(test1Entry.hasAction, "should set the 'hasAction' property to true for Test1");
|
||||
assert.isUndefined(test2Entry.hasAction, "should not set the 'hasAction' property for Test2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("import helpers", () => {
|
||||
it("should not crash in tsserver", () => {
|
||||
const f1 = {
|
||||
|
@ -4127,6 +4164,70 @@ namespace ts.projectSystem {
|
|||
});
|
||||
});
|
||||
|
||||
describe("completion entry with code actions", () => {
|
||||
it("should work for symbols from non-imported modules", () => {
|
||||
const moduleFile = {
|
||||
path: "/a/b/moduleFile.ts",
|
||||
content: `export const guitar = 10;`
|
||||
};
|
||||
const file1 = {
|
||||
path: "/a/b/file2.ts",
|
||||
content: `var x:`
|
||||
};
|
||||
const globalFile = {
|
||||
path: "/a/b/globalFile.ts",
|
||||
content: `interface Jazz { }`
|
||||
};
|
||||
const ambientModuleFile = {
|
||||
path: "/a/b/ambientModuleFile.ts",
|
||||
content:
|
||||
`declare module "windyAndWarm" {
|
||||
export const chetAtkins = "great";
|
||||
}`
|
||||
};
|
||||
const defaultModuleFile = {
|
||||
path: "/a/b/defaultModuleFile.ts",
|
||||
content:
|
||||
`export default function egyptianElla() { };`
|
||||
};
|
||||
const configFile = {
|
||||
path: "/a/b/tsconfig.json",
|
||||
content: "{}"
|
||||
};
|
||||
|
||||
const host = createServerHost([moduleFile, file1, globalFile, ambientModuleFile, defaultModuleFile, configFile]);
|
||||
const session = createSession(host);
|
||||
const projectService = session.getProjectService();
|
||||
projectService.openClientFile(file1.path);
|
||||
|
||||
checkEntryDetail(1, "guitar", /*hasAction*/ true, `import { guitar } from "./moduleFile";\n\n`);
|
||||
checkEntryDetail(1, "chetAtkins", /*hasAction*/ true, `import { chetAtkins } from "windyAndWarm";\n\n`);
|
||||
checkEntryDetail(1, "egyptianElla", /*hasAction*/ true, `import egyptianElla from "./defaultModuleFile";\n\n`);
|
||||
checkEntryDetail(7, "Jazz", /*hasAction*/ false);
|
||||
|
||||
function checkEntryDetail(offset: number, entryName: string, hasAction: boolean, insertString?: string) {
|
||||
const request = makeSessionRequest<protocol.CompletionDetailsRequestArgs>(
|
||||
CommandNames.CompletionDetails,
|
||||
{ entryNames: [entryName], file: file1.path, line: 1, offset, projectFileName: configFile.path });
|
||||
const response = session.executeCommand(request).response as protocol.CompletionEntryDetails[];
|
||||
assert.equal(response.length, 1);
|
||||
|
||||
const entryDetails = response[0];
|
||||
if (!hasAction) {
|
||||
assert.isUndefined(entryDetails.codeActions);
|
||||
}
|
||||
else {
|
||||
const action = entryDetails.codeActions[0];
|
||||
assert.equal(action.changes[0].fileName, file1.path);
|
||||
assert.deepEqual(action.changes[0], <protocol.FileCodeEdits>{
|
||||
fileName: file1.path,
|
||||
textChanges: [{ start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 }, newText: insertString }]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxNodeModuleJsDepth for inferred projects", () => {
|
||||
it("should be set to 2 if the project has js root files", () => {
|
||||
const file1: FileOrFolder = {
|
||||
|
|
|
@ -198,7 +198,9 @@ namespace ts.server {
|
|||
const request = this.processRequest<protocol.CompletionDetailsRequest>(CommandNames.CompletionDetails, args);
|
||||
const response = this.processResponse<protocol.CompletionDetailsResponse>(request);
|
||||
Debug.assert(response.body.length === 1, "Unexpected length of completion details response body.");
|
||||
return response.body[0];
|
||||
|
||||
const convertedCodeActions = map(response.body[0].codeActions, codeAction => this.convertCodeActions(codeAction, fileName));
|
||||
return { ...response.body[0], codeActions: convertedCodeActions };
|
||||
}
|
||||
|
||||
getCompletionEntrySymbol(_fileName: string, _position: number, _entryName: string): Symbol {
|
||||
|
|
|
@ -1659,6 +1659,11 @@ namespace ts.server.protocol {
|
|||
* this span should be used instead of the default one.
|
||||
*/
|
||||
replacementSpan?: TextSpan;
|
||||
/**
|
||||
* Indicating if commiting this completion entry will require additional code action to be
|
||||
* made to avoid errors. The code action is normally adding an additional import statement.
|
||||
*/
|
||||
hasAction?: true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1691,6 +1696,11 @@ namespace ts.server.protocol {
|
|||
* JSDoc tags for the symbol.
|
||||
*/
|
||||
tags: JSDocTagInfo[];
|
||||
|
||||
/**
|
||||
* The associated code actions for this entry
|
||||
*/
|
||||
codeActions?: CodeAction[];
|
||||
}
|
||||
|
||||
export interface CompletionsResponse extends Response {
|
||||
|
|
|
@ -1186,9 +1186,15 @@ namespace ts.server {
|
|||
if (simplifiedResult) {
|
||||
return mapDefined(completions && completions.entries, entry => {
|
||||
if (completions.isMemberCompletion || (entry.name.toLowerCase().indexOf(prefix.toLowerCase()) === 0)) {
|
||||
const { name, kind, kindModifiers, sortText, replacementSpan } = entry;
|
||||
const { name, kind, kindModifiers, sortText, replacementSpan, hasAction } = entry;
|
||||
const convertedSpan = replacementSpan ? this.decorateSpan(replacementSpan, scriptInfo) : undefined;
|
||||
return { name, kind, kindModifiers, sortText, replacementSpan: convertedSpan };
|
||||
|
||||
const newEntry: protocol.CompletionEntry = { name, kind, kindModifiers, sortText, replacementSpan: convertedSpan };
|
||||
// avoid serialization when hasAction = false
|
||||
if (hasAction) {
|
||||
newEntry.hasAction = true;
|
||||
}
|
||||
return newEntry;
|
||||
}
|
||||
}).sort((a, b) => compareStrings(a.name, b.name));
|
||||
}
|
||||
|
@ -1201,9 +1207,18 @@ namespace ts.server {
|
|||
const { file, project } = this.getFileAndProject(args);
|
||||
const scriptInfo = project.getScriptInfoForNormalizedPath(file);
|
||||
const position = this.getPosition(args, scriptInfo);
|
||||
const formattingOptions = project.projectService.getFormatCodeOptions(file);
|
||||
|
||||
return mapDefined(args.entryNames, entryName =>
|
||||
project.getLanguageService().getCompletionEntryDetails(file, position, entryName));
|
||||
return mapDefined(args.entryNames, entryName => {
|
||||
const details = project.getLanguageService().getCompletionEntryDetails(file, position, entryName, formattingOptions);
|
||||
if (details) {
|
||||
const mappedCodeActions = map(details.codeActions, action => this.mapCodeAction(action, scriptInfo));
|
||||
return { ...details, codeActions: mappedCodeActions };
|
||||
}
|
||||
else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getCompileOnSaveAffectedFileList(args: protocol.FileRequestArgs): ReadonlyArray<protocol.CompileOnSaveAffectedFileListSingleProject> {
|
||||
|
|
|
@ -11,11 +11,28 @@ namespace ts.codefix {
|
|||
});
|
||||
|
||||
type ImportCodeActionKind = "CodeChange" | "InsertingIntoExistingImport" | "NewImport";
|
||||
// this is a module id -> module import declaration map
|
||||
type ImportDeclarationMap = AnyImportSyntax[][];
|
||||
|
||||
interface ImportCodeAction extends CodeAction {
|
||||
kind: ImportCodeActionKind;
|
||||
moduleSpecifier?: string;
|
||||
}
|
||||
|
||||
export interface ImportCodeFixContext {
|
||||
host: LanguageServiceHost;
|
||||
symbolName: string;
|
||||
newLineCharacter: string;
|
||||
rulesProvider: formatting.RulesProvider;
|
||||
sourceFile: SourceFile;
|
||||
checker: TypeChecker;
|
||||
compilerOptions: CompilerOptions;
|
||||
getCanonicalFileName: (fileName: string) => string;
|
||||
// this is a module id -> module import declaration map
|
||||
cachedImportDeclarations?: ImportDeclarationMap;
|
||||
symbolToken?: Node;
|
||||
}
|
||||
|
||||
enum ModuleSpecifierComparison {
|
||||
Better,
|
||||
Equal,
|
||||
|
@ -122,77 +139,54 @@ namespace ts.codefix {
|
|||
}
|
||||
}
|
||||
|
||||
function getImportCodeActions(context: CodeFixContext): ImportCodeAction[] {
|
||||
const sourceFile = context.sourceFile;
|
||||
const checker = context.program.getTypeChecker();
|
||||
const allSourceFiles = context.program.getSourceFiles();
|
||||
const useCaseSensitiveFileNames = context.host.useCaseSensitiveFileNames ? context.host.useCaseSensitiveFileNames() : false;
|
||||
|
||||
const token = getTokenAtPosition(sourceFile, context.span.start, /*includeJsDocComment*/ false);
|
||||
const name = token.getText();
|
||||
const symbolIdActionMap = new ImportCodeActionMap();
|
||||
|
||||
// this is a module id -> module import declaration map
|
||||
const cachedImportDeclarations: AnyImportSyntax[][] = [];
|
||||
let lastImportDeclaration: Node;
|
||||
|
||||
const currentTokenMeaning = getMeaningFromLocation(token);
|
||||
if (context.errorCode === Diagnostics._0_refers_to_a_UMD_global_but_the_current_file_is_a_module_Consider_adding_an_import_instead.code) {
|
||||
const umdSymbol = checker.getSymbolAtLocation(token);
|
||||
let symbol: ts.Symbol;
|
||||
let symbolName: string;
|
||||
if (umdSymbol.flags & ts.SymbolFlags.Alias) {
|
||||
symbol = checker.getAliasedSymbol(umdSymbol);
|
||||
symbolName = name;
|
||||
function createCodeAction(
|
||||
description: DiagnosticMessage,
|
||||
diagnosticArgs: string[],
|
||||
changes: FileTextChanges[],
|
||||
kind: ImportCodeActionKind,
|
||||
moduleSpecifier?: string): ImportCodeAction {
|
||||
return {
|
||||
description: formatMessage.apply(undefined, [undefined, description].concat(<any[]>diagnosticArgs)),
|
||||
changes,
|
||||
kind,
|
||||
moduleSpecifier
|
||||
};
|
||||
}
|
||||
else if (isJsxOpeningLikeElement(token.parent) && token.parent.tagName === token) {
|
||||
// The error wasn't for the symbolAtLocation, it was for the JSX tag itself, which needs access to e.g. `React`.
|
||||
symbol = checker.getAliasedSymbol(checker.resolveName(checker.getJsxNamespace(), token.parent.tagName, SymbolFlags.Value));
|
||||
symbolName = symbol.name;
|
||||
|
||||
function convertToImportCodeFixContext(context: CodeFixContext): ImportCodeFixContext {
|
||||
const useCaseSensitiveFileNames = context.host.useCaseSensitiveFileNames ? context.host.useCaseSensitiveFileNames() : false;
|
||||
const checker = context.program.getTypeChecker();
|
||||
const token = getTokenAtPosition(context.sourceFile, context.span.start, /*includeJsDocComment*/ false);
|
||||
return {
|
||||
host: context.host,
|
||||
newLineCharacter: context.newLineCharacter,
|
||||
rulesProvider: context.rulesProvider,
|
||||
sourceFile: context.sourceFile,
|
||||
checker,
|
||||
compilerOptions: context.program.getCompilerOptions(),
|
||||
cachedImportDeclarations: [],
|
||||
getCanonicalFileName: createGetCanonicalFileName(useCaseSensitiveFileNames),
|
||||
symbolName: token.getText(),
|
||||
symbolToken: token
|
||||
};
|
||||
}
|
||||
|
||||
export function getCodeActionForImport(moduleSymbol: Symbol, context: ImportCodeFixContext, symbolName: string, isDefault?: boolean, isNamespaceImport?: boolean): ImportCodeAction[] {
|
||||
const existingDeclarations = getImportDeclarations(moduleSymbol, context);
|
||||
if (existingDeclarations.length > 0) {
|
||||
// With an existing import statement, there are more than one actions the user can do.
|
||||
return getCodeActionsForExistingImport(moduleSymbol, context, symbolName, isDefault, isNamespaceImport, existingDeclarations);
|
||||
}
|
||||
else {
|
||||
Debug.fail("Either the symbol or the JSX namespace should be a UMD global if we got here");
|
||||
}
|
||||
|
||||
return getCodeActionForImport(symbol, symbolName, /*isDefault*/ false, /*isNamespaceImport*/ true);
|
||||
}
|
||||
|
||||
const candidateModules = checker.getAmbientModules();
|
||||
for (const otherSourceFile of allSourceFiles) {
|
||||
if (otherSourceFile !== sourceFile && isExternalOrCommonJsModule(otherSourceFile)) {
|
||||
candidateModules.push(otherSourceFile.symbol);
|
||||
return [getCodeActionForNewImport(context, moduleSymbol, symbolName, isDefault, /*existingModuleSpecifier*/ undefined, isNamespaceImport)];
|
||||
}
|
||||
}
|
||||
|
||||
for (const moduleSymbol of candidateModules) {
|
||||
context.cancellationToken.throwIfCancellationRequested();
|
||||
function getImportDeclarations(moduleSymbol: Symbol, context: ImportCodeFixContext) {
|
||||
const { checker, sourceFile } = context;
|
||||
|
||||
// check the default export
|
||||
const defaultExport = checker.tryGetMemberInModuleExports("default", moduleSymbol);
|
||||
if (defaultExport) {
|
||||
const localSymbol = getLocalSymbolForExportDefault(defaultExport);
|
||||
if (localSymbol && localSymbol.escapedName === name && checkSymbolHasMeaning(localSymbol, currentTokenMeaning)) {
|
||||
// check if this symbol is already used
|
||||
const symbolId = getUniqueSymbolId(localSymbol);
|
||||
symbolIdActionMap.addActions(symbolId, getCodeActionForImport(moduleSymbol, name, /*isNamespaceImport*/ true));
|
||||
}
|
||||
}
|
||||
|
||||
// "default" is a keyword and not a legal identifier for the import, so we don't expect it here
|
||||
Debug.assert(name !== "default");
|
||||
|
||||
// check exports with the same name
|
||||
const exportSymbolWithIdenticalName = checker.tryGetMemberInModuleExportsAndProperties(name, moduleSymbol);
|
||||
if (exportSymbolWithIdenticalName && checkSymbolHasMeaning(exportSymbolWithIdenticalName, currentTokenMeaning)) {
|
||||
const symbolId = getUniqueSymbolId(exportSymbolWithIdenticalName);
|
||||
symbolIdActionMap.addActions(symbolId, getCodeActionForImport(moduleSymbol, name));
|
||||
}
|
||||
}
|
||||
|
||||
return symbolIdActionMap.getAllActions();
|
||||
|
||||
function getImportDeclarations(moduleSymbol: Symbol) {
|
||||
const moduleSymbolId = getUniqueSymbolId(moduleSymbol);
|
||||
const cachedImportDeclarations = context.cachedImportDeclarations || [];
|
||||
const moduleSymbolId = getUniqueSymbolId(moduleSymbol, checker);
|
||||
|
||||
const cached = cachedImportDeclarations[moduleSymbolId];
|
||||
if (cached) {
|
||||
|
@ -216,157 +210,21 @@ namespace ts.codefix {
|
|||
}
|
||||
}
|
||||
|
||||
function getUniqueSymbolId(symbol: Symbol) {
|
||||
return getSymbolId(skipAlias(symbol, checker));
|
||||
function createChangeTracker(context: ImportCodeFixContext) {
|
||||
return textChanges.ChangeTracker.fromContext(context);
|
||||
}
|
||||
|
||||
function checkSymbolHasMeaning(symbol: Symbol, meaning: SemanticMeaning) {
|
||||
const declarations = symbol.getDeclarations();
|
||||
return declarations ? some(symbol.declarations, decl => !!(getMeaningFromDeclaration(decl) & meaning)) : false;
|
||||
}
|
||||
function getCodeActionForNewImport(
|
||||
context: ImportCodeFixContext,
|
||||
moduleSymbol: Symbol,
|
||||
symbolName: string,
|
||||
isDefault: boolean,
|
||||
moduleSpecifier: string | undefined,
|
||||
isNamespaceImport: boolean,
|
||||
): ImportCodeAction {
|
||||
const { sourceFile, getCanonicalFileName, newLineCharacter, host, compilerOptions } = context;
|
||||
let lastImportDeclaration: Node;
|
||||
|
||||
function getCodeActionForImport(moduleSymbol: Symbol, symbolName: string, isDefault?: boolean, isNamespaceImport?: boolean): ImportCodeAction[] {
|
||||
const existingDeclarations = getImportDeclarations(moduleSymbol);
|
||||
if (existingDeclarations.length > 0) {
|
||||
// With an existing import statement, there are more than one actions the user can do.
|
||||
return getCodeActionsForExistingImport(existingDeclarations);
|
||||
}
|
||||
else {
|
||||
return [getCodeActionForNewImport()];
|
||||
}
|
||||
|
||||
function getCodeActionsForExistingImport(declarations: (ImportDeclaration | ImportEqualsDeclaration)[]): ImportCodeAction[] {
|
||||
const actions: ImportCodeAction[] = [];
|
||||
|
||||
// It is possible that multiple import statements with the same specifier exist in the file.
|
||||
// e.g.
|
||||
//
|
||||
// import * as ns from "foo";
|
||||
// import { member1, member2 } from "foo";
|
||||
//
|
||||
// member3/**/ <-- cusor here
|
||||
//
|
||||
// in this case we should provie 2 actions:
|
||||
// 1. change "member3" to "ns.member3"
|
||||
// 2. add "member3" to the second import statement's import list
|
||||
// and it is up to the user to decide which one fits best.
|
||||
let namespaceImportDeclaration: ImportDeclaration | ImportEqualsDeclaration;
|
||||
let namedImportDeclaration: ImportDeclaration;
|
||||
let existingModuleSpecifier: string;
|
||||
for (const declaration of declarations) {
|
||||
if (declaration.kind === SyntaxKind.ImportDeclaration) {
|
||||
const namedBindings = declaration.importClause && declaration.importClause.namedBindings;
|
||||
if (namedBindings && namedBindings.kind === SyntaxKind.NamespaceImport) {
|
||||
// case:
|
||||
// import * as ns from "foo"
|
||||
namespaceImportDeclaration = declaration;
|
||||
}
|
||||
else {
|
||||
// cases:
|
||||
// import default from "foo"
|
||||
// import { bar } from "foo" or combination with the first one
|
||||
// import "foo"
|
||||
namedImportDeclaration = declaration;
|
||||
}
|
||||
existingModuleSpecifier = declaration.moduleSpecifier.getText();
|
||||
}
|
||||
else {
|
||||
// case:
|
||||
// import foo = require("foo")
|
||||
namespaceImportDeclaration = declaration;
|
||||
existingModuleSpecifier = getModuleSpecifierFromImportEqualsDeclaration(declaration);
|
||||
}
|
||||
}
|
||||
|
||||
if (namespaceImportDeclaration) {
|
||||
actions.push(getCodeActionForNamespaceImport(namespaceImportDeclaration));
|
||||
}
|
||||
|
||||
if (!isNamespaceImport && namedImportDeclaration && namedImportDeclaration.importClause &&
|
||||
(namedImportDeclaration.importClause.name || namedImportDeclaration.importClause.namedBindings)) {
|
||||
/**
|
||||
* If the existing import declaration already has a named import list, just
|
||||
* insert the identifier into that list.
|
||||
*/
|
||||
const fileTextChanges = getTextChangeForImportClause(namedImportDeclaration.importClause);
|
||||
const moduleSpecifierWithoutQuotes = stripQuotes(namedImportDeclaration.moduleSpecifier.getText());
|
||||
actions.push(createCodeAction(
|
||||
Diagnostics.Add_0_to_existing_import_declaration_from_1,
|
||||
[name, moduleSpecifierWithoutQuotes],
|
||||
fileTextChanges,
|
||||
"InsertingIntoExistingImport",
|
||||
moduleSpecifierWithoutQuotes
|
||||
));
|
||||
}
|
||||
else {
|
||||
// we need to create a new import statement, but the existing module specifier can be reused.
|
||||
actions.push(getCodeActionForNewImport(existingModuleSpecifier));
|
||||
}
|
||||
return actions;
|
||||
|
||||
function getModuleSpecifierFromImportEqualsDeclaration(declaration: ImportEqualsDeclaration) {
|
||||
if (declaration.moduleReference && declaration.moduleReference.kind === SyntaxKind.ExternalModuleReference) {
|
||||
return declaration.moduleReference.expression.getText();
|
||||
}
|
||||
return declaration.moduleReference.getText();
|
||||
}
|
||||
|
||||
function getTextChangeForImportClause(importClause: ImportClause): FileTextChanges[] {
|
||||
const importList = <NamedImports>importClause.namedBindings;
|
||||
const newImportSpecifier = createImportSpecifier(/*propertyName*/ undefined, createIdentifier(name));
|
||||
// case 1:
|
||||
// original text: import default from "module"
|
||||
// change to: import default, { name } from "module"
|
||||
// case 2:
|
||||
// original text: import {} from "module"
|
||||
// change to: import { name } from "module"
|
||||
if (!importList || importList.elements.length === 0) {
|
||||
const newImportClause = createImportClause(importClause.name, createNamedImports([newImportSpecifier]));
|
||||
return createChangeTracker().replaceNode(sourceFile, importClause, newImportClause).getChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* If the import list has one import per line, preserve that. Otherwise, insert on same line as last element
|
||||
* import {
|
||||
* foo
|
||||
* } from "./module";
|
||||
*/
|
||||
return createChangeTracker().insertNodeInListAfter(
|
||||
sourceFile,
|
||||
importList.elements[importList.elements.length - 1],
|
||||
newImportSpecifier).getChanges();
|
||||
}
|
||||
|
||||
function getCodeActionForNamespaceImport(declaration: ImportDeclaration | ImportEqualsDeclaration): ImportCodeAction {
|
||||
let namespacePrefix: string;
|
||||
if (declaration.kind === SyntaxKind.ImportDeclaration) {
|
||||
namespacePrefix = (<NamespaceImport>declaration.importClause.namedBindings).name.getText();
|
||||
}
|
||||
else {
|
||||
namespacePrefix = declaration.name.getText();
|
||||
}
|
||||
namespacePrefix = stripQuotes(namespacePrefix);
|
||||
|
||||
/**
|
||||
* Cases:
|
||||
* import * as ns from "mod"
|
||||
* import default, * as ns from "mod"
|
||||
* import ns = require("mod")
|
||||
*
|
||||
* Because there is no import list, we alter the reference to include the
|
||||
* namespace instead of altering the import declaration. For example, "foo" would
|
||||
* become "ns.foo"
|
||||
*/
|
||||
return createCodeAction(
|
||||
Diagnostics.Change_0_to_1,
|
||||
[name, `${namespacePrefix}.${name}`],
|
||||
createChangeTracker().replaceNode(sourceFile, token, createPropertyAccess(createIdentifier(namespacePrefix), name)).getChanges(),
|
||||
"CodeChange"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCodeActionForNewImport(moduleSpecifier?: string): ImportCodeAction {
|
||||
if (!lastImportDeclaration) {
|
||||
// insert after any existing imports
|
||||
for (let i = sourceFile.statements.length - 1; i >= 0; i--) {
|
||||
|
@ -378,22 +236,21 @@ namespace ts.codefix {
|
|||
}
|
||||
}
|
||||
|
||||
const getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames);
|
||||
const moduleSpecifierWithoutQuotes = stripQuotes(moduleSpecifier || getModuleSpecifierForNewImport());
|
||||
const changeTracker = createChangeTracker();
|
||||
const moduleSpecifierWithoutQuotes = stripQuotes(moduleSpecifier || getModuleSpecifierForNewImport(sourceFile, moduleSymbol, compilerOptions, getCanonicalFileName, host));
|
||||
const changeTracker = createChangeTracker(context);
|
||||
const importClause = isDefault
|
||||
? createImportClause(createIdentifier(symbolName), /*namedBindings*/ undefined)
|
||||
: isNamespaceImport
|
||||
? createImportClause(/*name*/ undefined, createNamespaceImport(createIdentifier(symbolName)))
|
||||
: createImportClause(/*name*/ undefined, createNamedImports([createImportSpecifier(/*propertyName*/ undefined, createIdentifier(symbolName))]));
|
||||
const moduleSpecifierLiteral = createLiteral(moduleSpecifierWithoutQuotes);
|
||||
moduleSpecifierLiteral.singleQuote = getSingleQuoteStyleFromExistingImports();
|
||||
moduleSpecifierLiteral.singleQuote = getSingleQuoteStyleFromExistingImports(sourceFile);
|
||||
const importDecl = createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, importClause, moduleSpecifierLiteral);
|
||||
if (!lastImportDeclaration) {
|
||||
changeTracker.insertNodeAt(sourceFile, getSourceFileImportLocation(sourceFile), importDecl, { suffix: `${context.newLineCharacter}${context.newLineCharacter}` });
|
||||
}
|
||||
else {
|
||||
changeTracker.insertNodeAfter(sourceFile, lastImportDeclaration, importDecl, { suffix: context.newLineCharacter });
|
||||
changeTracker.insertNodeAfter(sourceFile, lastImportDeclaration, importDecl, { suffix: newLineCharacter });
|
||||
}
|
||||
|
||||
// if this file doesn't have any import statements, insert an import statement and then insert a new line
|
||||
|
@ -406,6 +263,7 @@ namespace ts.codefix {
|
|||
"NewImport",
|
||||
moduleSpecifierWithoutQuotes
|
||||
);
|
||||
}
|
||||
|
||||
function getSourceFileImportLocation(node: SourceFile) {
|
||||
// For a source file, it is possible there are detached comments we should not skip
|
||||
|
@ -429,7 +287,7 @@ namespace ts.codefix {
|
|||
return position;
|
||||
}
|
||||
|
||||
function getSingleQuoteStyleFromExistingImports() {
|
||||
function getSingleQuoteStyleFromExistingImports(sourceFile: SourceFile) {
|
||||
const firstModuleSpecifier = forEach(sourceFile.statements, node => {
|
||||
if (isImportDeclaration(node) || isExportDeclaration(node)) {
|
||||
if (node.moduleSpecifier && isStringLiteral(node.moduleSpecifier)) {
|
||||
|
@ -447,32 +305,31 @@ namespace ts.codefix {
|
|||
}
|
||||
}
|
||||
|
||||
function getModuleSpecifierForNewImport() {
|
||||
const fileName = sourceFile.fileName;
|
||||
function getModuleSpecifierForNewImport(sourceFile: SourceFile, moduleSymbol: Symbol, options: CompilerOptions, getCanonicalFileName: (file: string) => string, host: LanguageServiceHost) {
|
||||
const moduleFileName = moduleSymbol.valueDeclaration.getSourceFile().fileName;
|
||||
const sourceDirectory = getDirectoryPath(fileName);
|
||||
const options = context.program.getCompilerOptions();
|
||||
const sourceDirectory = getDirectoryPath(sourceFile.fileName);
|
||||
|
||||
return tryGetModuleNameFromAmbientModule() ||
|
||||
tryGetModuleNameFromTypeRoots() ||
|
||||
tryGetModuleNameAsNodeModule() ||
|
||||
tryGetModuleNameFromBaseUrl() ||
|
||||
tryGetModuleNameFromRootDirs() ||
|
||||
removeFileExtension(getRelativePath(moduleFileName, sourceDirectory));
|
||||
return tryGetModuleNameFromAmbientModule(moduleSymbol) ||
|
||||
tryGetModuleNameFromTypeRoots(options, host, getCanonicalFileName, moduleFileName) ||
|
||||
tryGetModuleNameAsNodeModule(options, moduleFileName, host, getCanonicalFileName, sourceDirectory) ||
|
||||
tryGetModuleNameFromBaseUrl(options, moduleFileName, getCanonicalFileName) ||
|
||||
tryGetModuleNameFromRootDirs(options, moduleFileName, sourceDirectory, getCanonicalFileName) ||
|
||||
removeFileExtension(getRelativePath(moduleFileName, sourceDirectory, getCanonicalFileName));
|
||||
}
|
||||
|
||||
function tryGetModuleNameFromAmbientModule(): string {
|
||||
function tryGetModuleNameFromAmbientModule(moduleSymbol: Symbol): string | undefined {
|
||||
const decl = moduleSymbol.valueDeclaration;
|
||||
if (isModuleDeclaration(decl) && isStringLiteral(decl.name)) {
|
||||
return decl.name.text;
|
||||
}
|
||||
}
|
||||
|
||||
function tryGetModuleNameFromBaseUrl() {
|
||||
function tryGetModuleNameFromBaseUrl(options: CompilerOptions, moduleFileName: string, getCanonicalFileName: (file: string) => string): string | undefined {
|
||||
if (!options.baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let relativeName = getRelativePathIfInDirectory(moduleFileName, options.baseUrl);
|
||||
let relativeName = getRelativePathIfInDirectory(moduleFileName, options.baseUrl, getCanonicalFileName);
|
||||
if (!relativeName) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -507,21 +364,24 @@ namespace ts.codefix {
|
|||
return relativeName;
|
||||
}
|
||||
|
||||
function tryGetModuleNameFromRootDirs() {
|
||||
function tryGetModuleNameFromRootDirs(options: CompilerOptions, moduleFileName: string, sourceDirectory: string, getCanonicalFileName: (file: string) => string): string | undefined {
|
||||
if (options.rootDirs) {
|
||||
const normalizedTargetPath = getPathRelativeToRootDirs(moduleFileName, options.rootDirs);
|
||||
const normalizedSourcePath = getPathRelativeToRootDirs(sourceDirectory, options.rootDirs);
|
||||
const normalizedTargetPath = getPathRelativeToRootDirs(moduleFileName, options.rootDirs, getCanonicalFileName);
|
||||
const normalizedSourcePath = getPathRelativeToRootDirs(sourceDirectory, options.rootDirs, getCanonicalFileName);
|
||||
if (normalizedTargetPath !== undefined) {
|
||||
const relativePath = normalizedSourcePath !== undefined ? getRelativePath(normalizedTargetPath, normalizedSourcePath) : normalizedTargetPath;
|
||||
const relativePath = normalizedSourcePath !== undefined ? getRelativePath(normalizedTargetPath, normalizedSourcePath, getCanonicalFileName) : normalizedTargetPath;
|
||||
return removeFileExtension(relativePath);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tryGetModuleNameFromTypeRoots() {
|
||||
const typeRoots = getEffectiveTypeRoots(options, context.host);
|
||||
if (typeRoots) {
|
||||
function tryGetModuleNameFromTypeRoots(options: CompilerOptions, host: LanguageServiceHost, getCanonicalFileName: (file: string) => string, moduleFileName: string): string | undefined {
|
||||
const typeRoots = getEffectiveTypeRoots(options, host);
|
||||
if (!typeRoots) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedTypeRoots = map(typeRoots, typeRoot => toPath(typeRoot, /*basePath*/ undefined, getCanonicalFileName));
|
||||
for (const typeRoot of normalizedTypeRoots) {
|
||||
if (startsWith(moduleFileName, typeRoot)) {
|
||||
|
@ -530,9 +390,14 @@ namespace ts.codefix {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryGetModuleNameAsNodeModule() {
|
||||
function tryGetModuleNameAsNodeModule(
|
||||
options: CompilerOptions,
|
||||
moduleFileName: string,
|
||||
host: LanguageServiceHost,
|
||||
getCanonicalFileName: (file: string) => string,
|
||||
sourceDirectory: string,
|
||||
): string | undefined {
|
||||
if (getEmitModuleResolutionKind(options) !== ModuleResolutionKind.NodeJs) {
|
||||
// nothing to do here
|
||||
return undefined;
|
||||
|
@ -557,8 +422,8 @@ namespace ts.codefix {
|
|||
// If the file is the main module, it can be imported by the package name
|
||||
const packageRootPath = path.substring(0, parts.packageRootIndex);
|
||||
const packageJsonPath = combinePaths(packageRootPath, "package.json");
|
||||
if (context.host.fileExists(packageJsonPath)) {
|
||||
const packageJsonContent = JSON.parse(context.host.readFile(packageJsonPath));
|
||||
if (host.fileExists(packageJsonPath)) {
|
||||
const packageJsonContent = JSON.parse(host.readFile(packageJsonPath));
|
||||
if (packageJsonContent) {
|
||||
const mainFileRelative = packageJsonContent.typings || packageJsonContent.types || packageJsonContent.main;
|
||||
if (mainFileRelative) {
|
||||
|
@ -588,8 +453,7 @@ namespace ts.codefix {
|
|||
return path.substring(parts.topLevelPackageNameIndex + 1);
|
||||
}
|
||||
else {
|
||||
return getRelativePath(path, sourceDirectory);
|
||||
}
|
||||
return getRelativePath(path, sourceDirectory, getCanonicalFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -652,9 +516,9 @@ namespace ts.codefix {
|
|||
return state > States.NodeModules ? { topLevelNodeModulesIndex, topLevelPackageNameIndex, packageRootIndex, fileNameIndex } : undefined;
|
||||
}
|
||||
|
||||
function getPathRelativeToRootDirs(path: string, rootDirs: string[]) {
|
||||
function getPathRelativeToRootDirs(path: string, rootDirs: string[], getCanonicalFileName: (fileName: string) => string) {
|
||||
for (const rootDir of rootDirs) {
|
||||
const relativeName = getRelativePathIfInDirectory(path, rootDir);
|
||||
const relativeName = getRelativePathIfInDirectory(path, rootDir, getCanonicalFileName);
|
||||
if (relativeName !== undefined) {
|
||||
return relativeName;
|
||||
}
|
||||
|
@ -670,35 +534,213 @@ namespace ts.codefix {
|
|||
return fileName;
|
||||
}
|
||||
|
||||
function getRelativePathIfInDirectory(path: string, directoryPath: string) {
|
||||
function getRelativePathIfInDirectory(path: string, directoryPath: string, getCanonicalFileName: (fileName: string) => string) {
|
||||
const relativePath = getRelativePathToDirectoryOrUrl(directoryPath, path, directoryPath, getCanonicalFileName, /*isAbsolutePathAnUrl*/ false);
|
||||
return isRootedDiskPath(relativePath) || startsWith(relativePath, "..") ? undefined : relativePath;
|
||||
}
|
||||
|
||||
function getRelativePath(path: string, directoryPath: string) {
|
||||
function getRelativePath(path: string, directoryPath: string, getCanonicalFileName: (fileName: string) => string) {
|
||||
const relativePath = getRelativePathToDirectoryOrUrl(directoryPath, path, directoryPath, getCanonicalFileName, /*isAbsolutePathAnUrl*/ false);
|
||||
return !pathIsRelative(relativePath) ? "./" + relativePath : relativePath;
|
||||
}
|
||||
|
||||
function getCodeActionsForExistingImport(moduleSymbol: Symbol, context: ImportCodeFixContext, symbolName: string, isDefault: boolean, isNamespaceImport: boolean, declarations: AnyImportSyntax[]): ImportCodeAction[] {
|
||||
const { symbolName: name, sourceFile, symbolToken } = context;
|
||||
const { namespaceImportDeclaration, namedImportDeclaration, existingModuleSpecifier } = getDeclarations(declarations);
|
||||
|
||||
const actions: ImportCodeAction[] = [];
|
||||
if (symbolToken && namespaceImportDeclaration) {
|
||||
actions.push(getCodeActionForNamespaceImport(namespaceImportDeclaration));
|
||||
}
|
||||
|
||||
if (!isNamespaceImport && namedImportDeclaration && namedImportDeclaration.importClause &&
|
||||
(namedImportDeclaration.importClause.name || namedImportDeclaration.importClause.namedBindings)) {
|
||||
/**
|
||||
* If the existing import declaration already has a named import list, just
|
||||
* insert the identifier into that list.
|
||||
*/
|
||||
const fileTextChanges = getTextChangeForImportClause(namedImportDeclaration.importClause);
|
||||
const moduleSpecifierWithoutQuotes = stripQuotes(namedImportDeclaration.moduleSpecifier.getText());
|
||||
actions.push(createCodeAction(
|
||||
Diagnostics.Add_0_to_existing_import_declaration_from_1,
|
||||
[name, moduleSpecifierWithoutQuotes],
|
||||
fileTextChanges,
|
||||
"InsertingIntoExistingImport",
|
||||
moduleSpecifierWithoutQuotes
|
||||
));
|
||||
}
|
||||
else {
|
||||
// we need to create a new import statement, but the existing module specifier can be reused.
|
||||
actions.push(getCodeActionForNewImport(context, moduleSymbol, symbolName, isDefault, existingModuleSpecifier, isNamespaceImport));
|
||||
}
|
||||
return actions;
|
||||
|
||||
function getTextChangeForImportClause(importClause: ImportClause): FileTextChanges[] {
|
||||
const importList = <NamedImports>importClause.namedBindings;
|
||||
const newImportSpecifier = createImportSpecifier(/*propertyName*/ undefined, createIdentifier(name));
|
||||
// case 1:
|
||||
// original text: import default from "module"
|
||||
// change to: import default, { name } from "module"
|
||||
// case 2:
|
||||
// original text: import {} from "module"
|
||||
// change to: import { name } from "module"
|
||||
if (!importList || importList.elements.length === 0) {
|
||||
const newImportClause = createImportClause(importClause.name, createNamedImports([newImportSpecifier]));
|
||||
return createChangeTracker(context).replaceNode(sourceFile, importClause, newImportClause).getChanges();
|
||||
}
|
||||
|
||||
function createChangeTracker() {
|
||||
return textChanges.ChangeTracker.fromContext(context);
|
||||
/**
|
||||
* If the import list has one import per line, preserve that. Otherwise, insert on same line as last element
|
||||
* import {
|
||||
* foo
|
||||
* } from "./module";
|
||||
*/
|
||||
return createChangeTracker(context).insertNodeInListAfter(
|
||||
sourceFile,
|
||||
importList.elements[importList.elements.length - 1],
|
||||
newImportSpecifier).getChanges();
|
||||
}
|
||||
|
||||
function createCodeAction(
|
||||
description: DiagnosticMessage,
|
||||
diagnosticArgs: string[],
|
||||
changes: FileTextChanges[],
|
||||
kind: ImportCodeActionKind,
|
||||
moduleSpecifier?: string): ImportCodeAction {
|
||||
return {
|
||||
description: formatMessage.apply(undefined, [undefined, description].concat(<any[]>diagnosticArgs)),
|
||||
changes,
|
||||
kind,
|
||||
moduleSpecifier
|
||||
};
|
||||
function getCodeActionForNamespaceImport(declaration: ImportDeclaration | ImportEqualsDeclaration): ImportCodeAction {
|
||||
const namespacePrefix = stripQuotes(declaration.kind === SyntaxKind.ImportDeclaration
|
||||
? (<NamespaceImport>declaration.importClause.namedBindings).name.getText()
|
||||
: declaration.name.getText());
|
||||
|
||||
/**
|
||||
* Cases:
|
||||
* import * as ns from "mod"
|
||||
* import default, * as ns from "mod"
|
||||
* import ns = require("mod")
|
||||
*
|
||||
* Because there is no import list, we alter the reference to include the
|
||||
* namespace instead of altering the import declaration. For example, "foo" would
|
||||
* become "ns.foo"
|
||||
*/
|
||||
return createCodeAction(
|
||||
Diagnostics.Change_0_to_1,
|
||||
[name, `${namespacePrefix}.${name}`],
|
||||
createChangeTracker(context).replaceNode(sourceFile, symbolToken, createPropertyAccess(createIdentifier(namespacePrefix), name)).getChanges(),
|
||||
"CodeChange"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getDeclarations(declarations: AnyImportSyntax[]) {
|
||||
// It is possible that multiple import statements with the same specifier exist in the file.
|
||||
// e.g.
|
||||
//
|
||||
// import * as ns from "foo";
|
||||
// import { member1, member2 } from "foo";
|
||||
//
|
||||
// member3/**/ <-- cusor here
|
||||
//
|
||||
// in this case we should provie 2 actions:
|
||||
// 1. change "member3" to "ns.member3"
|
||||
// 2. add "member3" to the second import statement's import list
|
||||
// and it is up to the user to decide which one fits best.
|
||||
let namespaceImportDeclaration: ImportDeclaration | ImportEqualsDeclaration;
|
||||
let namedImportDeclaration: ImportDeclaration;
|
||||
let existingModuleSpecifier: string;
|
||||
for (const declaration of declarations) {
|
||||
if (declaration.kind === SyntaxKind.ImportDeclaration) {
|
||||
const namedBindings = declaration.importClause && declaration.importClause.namedBindings;
|
||||
if (namedBindings && namedBindings.kind === SyntaxKind.NamespaceImport) {
|
||||
// case:
|
||||
// import * as ns from "foo"
|
||||
namespaceImportDeclaration = declaration;
|
||||
}
|
||||
else {
|
||||
// cases:
|
||||
// import default from "foo"
|
||||
// import { bar } from "foo" or combination with the first one
|
||||
// import "foo"
|
||||
namedImportDeclaration = declaration;
|
||||
}
|
||||
existingModuleSpecifier = declaration.moduleSpecifier.getText();
|
||||
}
|
||||
else {
|
||||
// case:
|
||||
// import foo = require("foo")
|
||||
namespaceImportDeclaration = declaration;
|
||||
existingModuleSpecifier = getModuleSpecifierFromImportEqualsDeclaration(declaration);
|
||||
}
|
||||
}
|
||||
return { namespaceImportDeclaration, namedImportDeclaration, existingModuleSpecifier };
|
||||
|
||||
function getModuleSpecifierFromImportEqualsDeclaration({ moduleReference }: ImportEqualsDeclaration): string {
|
||||
return (moduleReference.kind === SyntaxKind.ExternalModuleReference ? moduleReference.expression : moduleReference).getText();
|
||||
}
|
||||
}
|
||||
|
||||
function getImportCodeActions(context: CodeFixContext): ImportCodeAction[] {
|
||||
const sourceFile = context.sourceFile;
|
||||
const allSourceFiles = context.program.getSourceFiles();
|
||||
const importFixContext = convertToImportCodeFixContext(context);
|
||||
|
||||
const checker = importFixContext.checker;
|
||||
const token = importFixContext.symbolToken;
|
||||
const symbolIdActionMap = new ImportCodeActionMap();
|
||||
const currentTokenMeaning = getMeaningFromLocation(token);
|
||||
|
||||
const name = importFixContext.symbolName;
|
||||
|
||||
if (context.errorCode === Diagnostics._0_refers_to_a_UMD_global_but_the_current_file_is_a_module_Consider_adding_an_import_instead.code) {
|
||||
const umdSymbol = checker.getSymbolAtLocation(token);
|
||||
let symbol: ts.Symbol;
|
||||
let symbolName: string;
|
||||
if (umdSymbol.flags & ts.SymbolFlags.Alias) {
|
||||
symbol = checker.getAliasedSymbol(umdSymbol);
|
||||
symbolName = name;
|
||||
}
|
||||
else if (isJsxOpeningLikeElement(token.parent) && token.parent.tagName === token) {
|
||||
// The error wasn't for the symbolAtLocation, it was for the JSX tag itself, which needs access to e.g. `React`.
|
||||
symbol = checker.getAliasedSymbol(checker.resolveName(checker.getJsxNamespace(), token.parent.tagName, SymbolFlags.Value));
|
||||
symbolName = symbol.name;
|
||||
}
|
||||
else {
|
||||
Debug.fail("Either the symbol or the JSX namespace should be a UMD global if we got here");
|
||||
}
|
||||
|
||||
return getCodeActionForImport(symbol, importFixContext, symbolName, /*isDefault*/ false, /*isNamespaceImport*/ true);
|
||||
}
|
||||
|
||||
const candidateModules = checker.getAmbientModules();
|
||||
for (const otherSourceFile of allSourceFiles) {
|
||||
if (otherSourceFile !== sourceFile && isExternalOrCommonJsModule(otherSourceFile)) {
|
||||
candidateModules.push(otherSourceFile.symbol);
|
||||
}
|
||||
}
|
||||
|
||||
for (const moduleSymbol of candidateModules) {
|
||||
context.cancellationToken.throwIfCancellationRequested();
|
||||
|
||||
// check the default export
|
||||
const defaultExport = checker.tryGetMemberInModuleExports("default", moduleSymbol);
|
||||
if (defaultExport) {
|
||||
const localSymbol = getLocalSymbolForExportDefault(defaultExport);
|
||||
if (localSymbol && localSymbol.escapedName === name && checkSymbolHasMeaning(localSymbol, currentTokenMeaning)) {
|
||||
// check if this symbol is already used
|
||||
const symbolId = getUniqueSymbolId(localSymbol, checker);
|
||||
symbolIdActionMap.addActions(symbolId, getCodeActionForImport(moduleSymbol, importFixContext, name, /*isNamespaceImport*/ true));
|
||||
}
|
||||
}
|
||||
|
||||
// "default" is a keyword and not a legal identifier for the import, so we don't expect it here
|
||||
Debug.assert(name !== "default");
|
||||
|
||||
// check exports with the same name
|
||||
const exportSymbolWithIdenticalName = checker.tryGetMemberInModuleExportsAndProperties(name, moduleSymbol);
|
||||
if (exportSymbolWithIdenticalName && checkSymbolHasMeaning(exportSymbolWithIdenticalName, currentTokenMeaning)) {
|
||||
const symbolId = getUniqueSymbolId(exportSymbolWithIdenticalName, checker);
|
||||
symbolIdActionMap.addActions(symbolId, getCodeActionForImport(moduleSymbol, importFixContext, name));
|
||||
}
|
||||
}
|
||||
|
||||
return symbolIdActionMap.getAllActions();
|
||||
|
||||
function checkSymbolHasMeaning(symbol: Symbol, meaning: SemanticMeaning) {
|
||||
const declarations = symbol.getDeclarations();
|
||||
return declarations ? some(symbol.declarations, decl => !!(getMeaningFromDeclaration(decl) & meaning)) : false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,13 +4,23 @@
|
|||
namespace ts.Completions {
|
||||
export type Log = (message: string) => void;
|
||||
|
||||
export type SymbolOriginInfo = { moduleSymbol: Symbol, isDefaultExport?: boolean };
|
||||
|
||||
const enum KeywordCompletionFilters {
|
||||
None,
|
||||
ClassElementKeywords, // Keywords at class keyword
|
||||
ConstructorParameterKeywords, // Keywords at constructor parameter
|
||||
}
|
||||
|
||||
export function getCompletionsAtPosition(host: LanguageServiceHost, typeChecker: TypeChecker, log: Log, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number): CompletionInfo | undefined {
|
||||
export function getCompletionsAtPosition(
|
||||
host: LanguageServiceHost,
|
||||
typeChecker: TypeChecker,
|
||||
log: Log,
|
||||
compilerOptions: CompilerOptions,
|
||||
sourceFile: SourceFile,
|
||||
position: number,
|
||||
allSourceFiles: ReadonlyArray<SourceFile>,
|
||||
): CompletionInfo | undefined {
|
||||
if (isInReferenceComment(sourceFile, position)) {
|
||||
return PathCompletions.getTripleSlashReferenceCompletion(sourceFile, position, compilerOptions, host);
|
||||
}
|
||||
|
@ -19,12 +29,12 @@ namespace ts.Completions {
|
|||
return getStringLiteralCompletionEntries(sourceFile, position, typeChecker, compilerOptions, host, log);
|
||||
}
|
||||
|
||||
const completionData = getCompletionData(typeChecker, log, sourceFile, position);
|
||||
const completionData = getCompletionData(typeChecker, log, sourceFile, position, allSourceFiles);
|
||||
if (!completionData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { symbols, isGlobalCompletion, isMemberCompletion, allowStringLiteral, isNewIdentifierLocation, location, request, keywordFilters } = completionData;
|
||||
const { symbols, isGlobalCompletion, isMemberCompletion, allowStringLiteral, isNewIdentifierLocation, location, request, keywordFilters, symbolToOriginInfoMap } = completionData;
|
||||
|
||||
if (sourceFile.languageVariant === LanguageVariant.JSX &&
|
||||
location && location.parent && location.parent.kind === SyntaxKind.JsxClosingElement) {
|
||||
|
@ -56,7 +66,7 @@ namespace ts.Completions {
|
|||
const entries: CompletionEntry[] = [];
|
||||
|
||||
if (isSourceFileJavaScript(sourceFile)) {
|
||||
const uniqueNames = getCompletionEntriesFromSymbols(symbols, entries, location, /*performCharacterChecks*/ true, typeChecker, compilerOptions.target, log, allowStringLiteral);
|
||||
const uniqueNames = getCompletionEntriesFromSymbols(symbols, entries, location, /*performCharacterChecks*/ true, typeChecker, compilerOptions.target, log, allowStringLiteral, symbolToOriginInfoMap);
|
||||
getJavaScriptCompletionEntries(sourceFile, location.pos, uniqueNames, compilerOptions.target, entries);
|
||||
}
|
||||
else {
|
||||
|
@ -64,7 +74,7 @@ namespace ts.Completions {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
getCompletionEntriesFromSymbols(symbols, entries, location, /*performCharacterChecks*/ true, typeChecker, compilerOptions.target, log, allowStringLiteral);
|
||||
getCompletionEntriesFromSymbols(symbols, entries, location, /*performCharacterChecks*/ true, typeChecker, compilerOptions.target, log, allowStringLiteral, symbolToOriginInfoMap);
|
||||
}
|
||||
|
||||
// TODO add filter for keyword based on type/value/namespace and also location
|
||||
|
@ -134,7 +144,17 @@ namespace ts.Completions {
|
|||
};
|
||||
}
|
||||
|
||||
function getCompletionEntriesFromSymbols(symbols: Symbol[], entries: Push<CompletionEntry>, location: Node, performCharacterChecks: boolean, typeChecker: TypeChecker, target: ScriptTarget, log: Log, allowStringLiteral: boolean): Map<true> {
|
||||
function getCompletionEntriesFromSymbols(
|
||||
symbols: Symbol[],
|
||||
entries: Push<CompletionEntry>,
|
||||
location: Node,
|
||||
performCharacterChecks: boolean,
|
||||
typeChecker: TypeChecker,
|
||||
target: ScriptTarget,
|
||||
log: Log,
|
||||
allowStringLiteral: boolean,
|
||||
symbolToOriginInfoMap?: Map<SymbolOriginInfo>,
|
||||
): Map<true> {
|
||||
const start = timestamp();
|
||||
const uniqueNames = createMap<true>();
|
||||
if (symbols) {
|
||||
|
@ -143,6 +163,9 @@ namespace ts.Completions {
|
|||
if (entry) {
|
||||
const id = entry.name;
|
||||
if (!uniqueNames.has(id)) {
|
||||
if (symbolToOriginInfoMap && symbolToOriginInfoMap.has(getUniqueSymbolIdAsString(symbol, typeChecker))) {
|
||||
entry.hasAction = true;
|
||||
}
|
||||
entries.push(entry);
|
||||
uniqueNames.set(id, true);
|
||||
}
|
||||
|
@ -298,11 +321,22 @@ namespace ts.Completions {
|
|||
}
|
||||
}
|
||||
|
||||
export function getCompletionEntryDetails(typeChecker: TypeChecker, log: (message: string) => void, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number, entryName: string): CompletionEntryDetails {
|
||||
export function getCompletionEntryDetails(
|
||||
typeChecker: TypeChecker,
|
||||
log: (message: string) => void,
|
||||
compilerOptions: CompilerOptions,
|
||||
sourceFile: SourceFile,
|
||||
position: number,
|
||||
entryName: string,
|
||||
allSourceFiles: ReadonlyArray<SourceFile>,
|
||||
host?: LanguageServiceHost,
|
||||
rulesProvider?: formatting.RulesProvider,
|
||||
): CompletionEntryDetails {
|
||||
|
||||
// Compute all the completion symbols again.
|
||||
const completionData = getCompletionData(typeChecker, log, sourceFile, position);
|
||||
const completionData = getCompletionData(typeChecker, log, sourceFile, position, allSourceFiles);
|
||||
if (completionData) {
|
||||
const { symbols, location, allowStringLiteral } = completionData;
|
||||
const { symbols, location, allowStringLiteral, symbolToOriginInfoMap } = completionData;
|
||||
|
||||
// Find the symbol with the matching entry name.
|
||||
// We don't need to perform character checks here because we're only comparing the
|
||||
|
@ -311,6 +345,26 @@ namespace ts.Completions {
|
|||
const symbol = forEach(symbols, s => getCompletionEntryDisplayNameForSymbol(s, compilerOptions.target, /*performCharacterChecks*/ false, allowStringLiteral) === entryName ? s : undefined);
|
||||
|
||||
if (symbol) {
|
||||
let codeActions: CodeAction[];
|
||||
if (host && rulesProvider) {
|
||||
const symbolOriginInfo = symbolToOriginInfoMap.get(getUniqueSymbolIdAsString(symbol, typeChecker));
|
||||
if (symbolOriginInfo) {
|
||||
const useCaseSensitiveFileNames = host.useCaseSensitiveFileNames ? host.useCaseSensitiveFileNames() : false;
|
||||
const context: codefix.ImportCodeFixContext = {
|
||||
host,
|
||||
checker: typeChecker,
|
||||
newLineCharacter: host.getNewLine(),
|
||||
compilerOptions,
|
||||
sourceFile,
|
||||
rulesProvider,
|
||||
symbolName: symbol.name,
|
||||
getCanonicalFileName: createGetCanonicalFileName(useCaseSensitiveFileNames)
|
||||
};
|
||||
|
||||
codeActions = codefix.getCodeActionForImport(/*moduleSymbol*/ symbolOriginInfo.moduleSymbol, context, context.symbolName, /*isDefault*/ symbolOriginInfo.isDefaultExport);
|
||||
}
|
||||
}
|
||||
|
||||
const { displayParts, documentation, symbolKind, tags } = SymbolDisplay.getSymbolDisplayPartsDocumentationAndSymbolKind(typeChecker, symbol, sourceFile, location, location, SemanticMeaning.All);
|
||||
return {
|
||||
name: entryName,
|
||||
|
@ -318,7 +372,8 @@ namespace ts.Completions {
|
|||
kind: symbolKind,
|
||||
displayParts,
|
||||
documentation,
|
||||
tags
|
||||
tags,
|
||||
codeActions
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -335,16 +390,25 @@ namespace ts.Completions {
|
|||
kindModifiers: ScriptElementKindModifier.none,
|
||||
displayParts: [displayPart(entryName, SymbolDisplayPartKind.keyword)],
|
||||
documentation: undefined,
|
||||
tags: undefined
|
||||
tags: undefined,
|
||||
codeActions: undefined
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCompletionEntrySymbol(typeChecker: TypeChecker, log: (message: string) => void, compilerOptions: CompilerOptions, sourceFile: SourceFile, position: number, entryName: string): Symbol | undefined {
|
||||
export function getCompletionEntrySymbol(
|
||||
typeChecker: TypeChecker,
|
||||
log: (message: string) => void,
|
||||
compilerOptions: CompilerOptions,
|
||||
sourceFile: SourceFile,
|
||||
position: number,
|
||||
entryName: string,
|
||||
allSourceFiles: ReadonlyArray<SourceFile>,
|
||||
): Symbol | undefined {
|
||||
// Compute all the completion symbols again.
|
||||
const completionData = getCompletionData(typeChecker, log, sourceFile, position);
|
||||
const completionData = getCompletionData(typeChecker, log, sourceFile, position, allSourceFiles);
|
||||
if (!completionData) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -366,10 +430,17 @@ namespace ts.Completions {
|
|||
isRightOfDot: boolean;
|
||||
request?: Request;
|
||||
keywordFilters: KeywordCompletionFilters;
|
||||
symbolToOriginInfoMap: Map<SymbolOriginInfo>;
|
||||
}
|
||||
type Request = { kind: "JsDocTagName" } | { kind: "JsDocTag" } | { kind: "JsDocParameterName", tag: JSDocParameterTag };
|
||||
|
||||
function getCompletionData(typeChecker: TypeChecker, log: (message: string) => void, sourceFile: SourceFile, position: number): CompletionData | undefined {
|
||||
function getCompletionData(
|
||||
typeChecker: TypeChecker,
|
||||
log: (message: string) => void,
|
||||
sourceFile: SourceFile,
|
||||
position: number,
|
||||
allSourceFiles: ReadonlyArray<SourceFile>,
|
||||
): CompletionData | undefined {
|
||||
const isJavaScriptFile = isSourceFileJavaScript(sourceFile);
|
||||
|
||||
let request: Request | undefined;
|
||||
|
@ -441,7 +512,18 @@ namespace ts.Completions {
|
|||
}
|
||||
|
||||
if (request) {
|
||||
return { symbols: undefined, isGlobalCompletion: false, isMemberCompletion: false, allowStringLiteral: false, isNewIdentifierLocation: false, location: undefined, isRightOfDot: false, request, keywordFilters: KeywordCompletionFilters.None };
|
||||
return {
|
||||
symbols: undefined,
|
||||
isGlobalCompletion: false,
|
||||
isMemberCompletion: false,
|
||||
allowStringLiteral: false,
|
||||
isNewIdentifierLocation: false,
|
||||
location: undefined,
|
||||
isRightOfDot: false,
|
||||
request,
|
||||
keywordFilters: KeywordCompletionFilters.None,
|
||||
symbolToOriginInfoMap: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!insideJsDocTagTypeExpression) {
|
||||
|
@ -523,7 +605,6 @@ namespace ts.Completions {
|
|||
break;
|
||||
}
|
||||
// falls through
|
||||
|
||||
case SyntaxKind.JsxSelfClosingElement:
|
||||
case SyntaxKind.JsxElement:
|
||||
case SyntaxKind.JsxOpeningElement:
|
||||
|
@ -543,6 +624,7 @@ namespace ts.Completions {
|
|||
let isNewIdentifierLocation: boolean;
|
||||
let keywordFilters = KeywordCompletionFilters.None;
|
||||
let symbols: Symbol[] = [];
|
||||
const symbolToOriginInfoMap = createMap<SymbolOriginInfo>();
|
||||
|
||||
if (isRightOfDot) {
|
||||
getTypeScriptMemberSymbols();
|
||||
|
@ -579,7 +661,7 @@ namespace ts.Completions {
|
|||
|
||||
log("getCompletionData: Semantic work: " + (timestamp() - semanticStart));
|
||||
|
||||
return { symbols, isGlobalCompletion, isMemberCompletion, allowStringLiteral, isNewIdentifierLocation, location, isRightOfDot: (isRightOfDot || isRightOfOpenTag), request, keywordFilters };
|
||||
return { symbols, isGlobalCompletion, isMemberCompletion, allowStringLiteral, isNewIdentifierLocation, location, isRightOfDot: (isRightOfDot || isRightOfOpenTag), request, keywordFilters, symbolToOriginInfoMap };
|
||||
|
||||
type JSDocTagWithTypeExpression = JSDocAugmentsTag | JSDocParameterTag | JSDocPropertyTag | JSDocReturnTag | JSDocTypeTag | JSDocTypedefTag;
|
||||
|
||||
|
@ -753,7 +835,10 @@ namespace ts.Completions {
|
|||
}
|
||||
|
||||
const symbolMeanings = SymbolFlags.Type | SymbolFlags.Value | SymbolFlags.Namespace | SymbolFlags.Alias;
|
||||
symbols = filterGlobalCompletion(typeChecker.getSymbolsInScope(scopeNode, symbolMeanings));
|
||||
|
||||
symbols = typeChecker.getSymbolsInScope(scopeNode, symbolMeanings);
|
||||
symbols.push(...getSymbolsFromOtherSourceFileExports(symbols, previousToken === undefined ? "" : previousToken.getText()));
|
||||
symbols = filterGlobalCompletion(symbols);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -833,6 +918,36 @@ namespace ts.Completions {
|
|||
}
|
||||
}
|
||||
|
||||
function getSymbolsFromOtherSourceFileExports(knownSymbols: Symbol[], tokenText: string): Symbol[] {
|
||||
const otherSourceFileExports: Symbol[] = [];
|
||||
const tokenTextLowerCase = tokenText.toLowerCase();
|
||||
const symbolIdMap = arrayToMap(knownSymbols, s => getUniqueSymbolIdAsString(s, typeChecker));
|
||||
|
||||
eachOtherModuleSymbol(allSourceFiles, sourceFile, typeChecker, moduleSymbol => {
|
||||
// check the default export
|
||||
const defaultExport = typeChecker.tryGetMemberInModuleExports("default", moduleSymbol);
|
||||
if (defaultExport) {
|
||||
const localSymbol = getLocalSymbolForExportDefault(defaultExport);
|
||||
if (localSymbol && !symbolIdMap.has(getUniqueSymbolIdAsString(localSymbol, typeChecker)) && startsWith(localSymbol.name.toLowerCase(), tokenTextLowerCase)) {
|
||||
otherSourceFileExports.push(localSymbol);
|
||||
symbolToOriginInfoMap.set(getUniqueSymbolIdAsString(localSymbol, typeChecker), { moduleSymbol, isDefaultExport: true });
|
||||
}
|
||||
}
|
||||
|
||||
// check exports with the same name
|
||||
const allExportedSymbols = typeChecker.getExportsOfModule(moduleSymbol);
|
||||
if (allExportedSymbols) {
|
||||
for (const exportedSymbol of allExportedSymbols) {
|
||||
if (exportedSymbol.name && !symbolIdMap.has(getUniqueSymbolIdAsString(exportedSymbol, typeChecker)) && startsWith(exportedSymbol.name.toLowerCase(), tokenTextLowerCase)) {
|
||||
otherSourceFileExports.push(exportedSymbol);
|
||||
symbolToOriginInfoMap.set(getUniqueSymbolIdAsString(exportedSymbol, typeChecker), { moduleSymbol });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return otherSourceFileExports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first node that "embraces" the position, so that one may
|
||||
* accurately aggregate locals from the closest containing scope.
|
||||
|
@ -1782,4 +1897,21 @@ namespace ts.Completions {
|
|||
// If there are no property-only types, just provide completions for every type as usual.
|
||||
return checker.getAllPossiblePropertiesOfTypes(filteredTypes);
|
||||
}
|
||||
|
||||
function eachOtherModuleSymbol(
|
||||
sourceFiles: ReadonlyArray<SourceFile>,
|
||||
currentSourceFile: SourceFile,
|
||||
typeChecker: TypeChecker,
|
||||
cb: (symbol: Symbol) => void,
|
||||
) {
|
||||
for (const a of typeChecker.getAmbientModules()) {
|
||||
cb(a);
|
||||
}
|
||||
|
||||
for (const otherSourceFile of sourceFiles) {
|
||||
if (otherSourceFile !== currentSourceFile && isExternalOrCommonJsModule(otherSourceFile)) {
|
||||
cb(otherSourceFile.symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1377,17 +1377,19 @@ namespace ts {
|
|||
|
||||
function getCompletionsAtPosition(fileName: string, position: number): CompletionInfo {
|
||||
synchronizeHostData();
|
||||
return Completions.getCompletionsAtPosition(host, program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position);
|
||||
return Completions.getCompletionsAtPosition(host, program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, program.getSourceFiles());
|
||||
}
|
||||
|
||||
function getCompletionEntryDetails(fileName: string, position: number, entryName: string): CompletionEntryDetails {
|
||||
function getCompletionEntryDetails(fileName: string, position: number, entryName: string, formattingOptions?: FormatCodeSettings): CompletionEntryDetails {
|
||||
synchronizeHostData();
|
||||
return Completions.getCompletionEntryDetails(program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, entryName);
|
||||
const ruleProvider = formattingOptions ? getRuleProvider(formattingOptions) : undefined;
|
||||
return Completions.getCompletionEntryDetails(
|
||||
program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, entryName, program.getSourceFiles(), host, ruleProvider);
|
||||
}
|
||||
|
||||
function getCompletionEntrySymbol(fileName: string, position: number, entryName: string): Symbol {
|
||||
synchronizeHostData();
|
||||
return Completions.getCompletionEntrySymbol(program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, entryName);
|
||||
return Completions.getCompletionEntrySymbol(program.getTypeChecker(), log, program.getCompilerOptions(), getValidSourceFile(fileName), position, entryName, program.getSourceFiles());
|
||||
}
|
||||
|
||||
function getQuickInfoAtPosition(fileName: string, position: number): QuickInfo {
|
||||
|
|
|
@ -190,7 +190,8 @@ namespace ts.textChanges {
|
|||
private changes: Change[] = [];
|
||||
private readonly newLineCharacter: string;
|
||||
|
||||
public static fromContext(context: RefactorContext | CodeFixContext) {
|
||||
//todo: don't include ImportCodeFixContext
|
||||
public static fromContext(context: RefactorContext | CodeFixContext | ts.codefix.ImportCodeFixContext) {
|
||||
return new ChangeTracker(context.newLineCharacter === "\n" ? NewLineKind.LineFeed : NewLineKind.CarriageReturnLineFeed, context.rulesProvider);
|
||||
}
|
||||
|
||||
|
|
|
@ -228,7 +228,8 @@ namespace ts {
|
|||
getEncodedSemanticClassifications(fileName: string, span: TextSpan): Classifications;
|
||||
|
||||
getCompletionsAtPosition(fileName: string, position: number): CompletionInfo;
|
||||
getCompletionEntryDetails(fileName: string, position: number, entryName: string): CompletionEntryDetails;
|
||||
//We should probably not take formatting options here.
|
||||
getCompletionEntryDetails(fileName: string, position: number, entryName: string, formattingOptions?: FormatCodeSettings): CompletionEntryDetails;
|
||||
getCompletionEntrySymbol(fileName: string, position: number, entryName: string): Symbol;
|
||||
|
||||
getQuickInfoAtPosition(fileName: string, position: number): QuickInfo;
|
||||
|
@ -665,6 +666,7 @@ namespace ts {
|
|||
* be used in that case
|
||||
*/
|
||||
replacementSpan?: TextSpan;
|
||||
hasAction?: true; //why do we need this?
|
||||
}
|
||||
|
||||
export interface CompletionEntryDetails {
|
||||
|
@ -674,6 +676,7 @@ namespace ts {
|
|||
displayParts: SymbolDisplayPart[];
|
||||
documentation: SymbolDisplayPart[];
|
||||
tags: JSDocTagInfo[];
|
||||
codeActions?: CodeAction[];
|
||||
}
|
||||
|
||||
export interface OutliningSpan {
|
||||
|
|
|
@ -1313,10 +1313,21 @@ namespace ts {
|
|||
|
||||
export function getScriptKind(fileName: string, host?: LanguageServiceHost): ScriptKind {
|
||||
// First check to see if the script kind was specified by the host. Chances are the host
|
||||
// may override the default script kind for the file extension.
|
||||
// may override the default script kind for the file extensison.
|
||||
return ensureScriptKind(fileName, host && host.getScriptKind && host.getScriptKind(fileName));
|
||||
}
|
||||
|
||||
export function getUniqueSymbolIdAsString(symbol: Symbol, typeChecker: TypeChecker) {
|
||||
return getUniqueSymbolId(symbol, typeChecker) + "";
|
||||
}
|
||||
|
||||
export function getUniqueSymbolId(symbol: Symbol, typeChecker: TypeChecker) {
|
||||
if (symbol.flags & SymbolFlags.Alias) {
|
||||
return getSymbolId(typeChecker.getAliasedSymbol(symbol));
|
||||
}
|
||||
return getSymbolId(symbol);
|
||||
}
|
||||
|
||||
export function getFirstNonSpaceCharacterPosition(text: string, position: number) {
|
||||
while (isWhiteSpaceLike(text.charCodeAt(position))) {
|
||||
position += 1;
|
||||
|
|
Loading…
Reference in a new issue