Skip to content

Commit 4076ff8

Browse files
authored
Add option for organize imports case sensitivity (#51733)
* Add ignore case option to organizeImports * Adopt in auto-imports, use same case-insensitive comparison as eslint * Fix build/lint * Mark functions internal * Update affected auto import test * Update API baseline * Update protocol * Update API baseline * Short-circuit comparisons that have already failed
1 parent a5dde88 commit 4076ff8

19 files changed

+431
-110
lines changed

Diff for: src/compiler/core.ts

+79-4
Original file line numberDiff line numberDiff line change
@@ -944,16 +944,53 @@ export function sortAndDeduplicate<T>(array: readonly T[], comparer?: Comparer<T
944944
/** @internal */
945945
export function arrayIsSorted<T>(array: readonly T[], comparer: Comparer<T>) {
946946
if (array.length < 2) return true;
947-
let prevElement = array[0];
948-
for (const element of array.slice(1)) {
949-
if (comparer(prevElement, element) === Comparison.GreaterThan) {
947+
for (let i = 1, len = array.length; i < len; i++) {
948+
if (comparer(array[i - 1], array[i]) === Comparison.GreaterThan) {
950949
return false;
951950
}
952-
prevElement = element;
953951
}
954952
return true;
955953
}
956954

955+
/** @internal */
956+
export const enum SortKind {
957+
None = 0,
958+
CaseSensitive = 1 << 0,
959+
CaseInsensitive = 1 << 1,
960+
Both = CaseSensitive | CaseInsensitive,
961+
}
962+
963+
/** @internal */
964+
export function detectSortCaseSensitivity(array: readonly string[], useEslintOrdering?: boolean): SortKind;
965+
/** @internal */
966+
export function detectSortCaseSensitivity<T>(array: readonly T[], useEslintOrdering: boolean, getString: (element: T) => string): SortKind;
967+
/** @internal */
968+
export function detectSortCaseSensitivity<T>(array: readonly T[], useEslintOrdering: boolean, getString?: (element: T) => string): SortKind {
969+
let kind = SortKind.Both;
970+
if (array.length < 2) return kind;
971+
const caseSensitiveComparer = getString
972+
? (a: T, b: T) => compareStringsCaseSensitive(getString(a), getString(b))
973+
: compareStringsCaseSensitive as (a: T | undefined, b: T | undefined) => Comparison;
974+
const compareCaseInsensitive = useEslintOrdering ? compareStringsCaseInsensitiveEslintCompatible : compareStringsCaseInsensitive;
975+
const caseInsensitiveComparer = getString
976+
? (a: T, b: T) => compareCaseInsensitive(getString(a), getString(b))
977+
: compareCaseInsensitive as (a: T | undefined, b: T | undefined) => Comparison;
978+
for (let i = 1, len = array.length; i < len; i++) {
979+
const prevElement = array[i - 1];
980+
const element = array[i];
981+
if (kind & SortKind.CaseSensitive && caseSensitiveComparer(prevElement, element) === Comparison.GreaterThan) {
982+
kind &= ~SortKind.CaseSensitive;
983+
}
984+
if (kind & SortKind.CaseInsensitive && caseInsensitiveComparer(prevElement, element) === Comparison.GreaterThan) {
985+
kind &= ~SortKind.CaseInsensitive;
986+
}
987+
if (kind === SortKind.None) {
988+
return kind;
989+
}
990+
}
991+
return kind;
992+
}
993+
957994
/** @internal */
958995
export function arrayIsEqualTo<T>(array1: readonly T[] | undefined, array2: readonly T[] | undefined, equalityComparer: (a: T, b: T, index: number) => boolean = equateValues): boolean {
959996
if (!array1 || !array2) {
@@ -2144,6 +2181,23 @@ export function memoizeOne<A extends string | number | boolean | undefined, T>(c
21442181
};
21452182
}
21462183

2184+
/**
2185+
* A version of `memoize` that supports a single non-primitive argument, stored as keys of a WeakMap.
2186+
*
2187+
* @internal
2188+
*/
2189+
export function memoizeWeak<A extends object, T>(callback: (arg: A) => T): (arg: A) => T {
2190+
const map = new WeakMap<A, T>();
2191+
return (arg: A) => {
2192+
let value = map.get(arg);
2193+
if (value === undefined && !map.has(arg)) {
2194+
value = callback(arg);
2195+
map.set(arg, value);
2196+
}
2197+
return value!;
2198+
};
2199+
}
2200+
21472201
/**
21482202
* High-order function, composes functions. Note that functions are composed inside-out;
21492203
* for example, `compose(a, b)` is the equivalent of `x => b(a(x))`.
@@ -2293,6 +2347,27 @@ export function compareStringsCaseInsensitive(a: string, b: string) {
22932347
return a < b ? Comparison.LessThan : a > b ? Comparison.GreaterThan : Comparison.EqualTo;
22942348
}
22952349

2350+
/**
2351+
* `compareStringsCaseInsensitive` transforms letters to uppercase for unicode reasons,
2352+
* while eslint's `sort-imports` rule transforms letters to lowercase. Which one you choose
2353+
* affects the relative order of letters and ASCII characters 91-96, of which `_` is a
2354+
* valid character in an identifier. So if we used `compareStringsCaseInsensitive` for
2355+
* import sorting, TypeScript and eslint would disagree about the correct case-insensitive
2356+
* sort order for `__String` and `Foo`. Since eslint's whole job is to create consistency
2357+
* by enforcing nitpicky details like this, it makes way more sense for us to just adopt
2358+
* their convention so users can have auto-imports without making eslint angry.
2359+
*
2360+
* @internal
2361+
*/
2362+
export function compareStringsCaseInsensitiveEslintCompatible(a: string, b: string) {
2363+
if (a === b) return Comparison.EqualTo;
2364+
if (a === undefined) return Comparison.LessThan;
2365+
if (b === undefined) return Comparison.GreaterThan;
2366+
a = a.toLowerCase();
2367+
b = b.toLowerCase();
2368+
return a < b ? Comparison.LessThan : a > b ? Comparison.GreaterThan : Comparison.EqualTo;
2369+
}
2370+
22962371
/**
22972372
* Compare two strings using a case-sensitive ordinal comparison.
22982373
*

Diff for: src/compiler/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9740,6 +9740,7 @@ export interface UserPreferences {
97409740
readonly includeInlayEnumMemberValueHints?: boolean;
97419741
readonly allowRenameOfImportPath?: boolean;
97429742
readonly autoImportFileExcludePatterns?: string[];
9743+
readonly organizeImportsIgnoreCase?: "auto" | boolean;
97439744
}
97449745

97459746
/** Represents a bigint literal value without requiring bigint support */

Diff for: src/harness/fourslashImpl.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -535,8 +535,8 @@ export class TestState {
535535
}
536536
}
537537

538-
public verifyOrganizeImports(newContent: string, mode?: ts.OrganizeImportsMode) {
539-
const changes = this.languageService.organizeImports({ fileName: this.activeFile.fileName, type: "file", mode }, this.formatCodeSettings, ts.emptyOptions);
538+
public verifyOrganizeImports(newContent: string, mode?: ts.OrganizeImportsMode, preferences?: ts.UserPreferences) {
539+
const changes = this.languageService.organizeImports({ fileName: this.activeFile.fileName, type: "file", mode }, this.formatCodeSettings, preferences);
540540
this.applyChanges(changes);
541541
this.verifyFileContent(this.activeFile.fileName, newContent);
542542
}

Diff for: src/harness/fourslashInterfaceImpl.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,8 @@ export class Verify extends VerifyNegatable {
630630
this.state.noMoveToNewFile();
631631
}
632632

633-
public organizeImports(newContent: string, mode?: ts.OrganizeImportsMode): void {
634-
this.state.verifyOrganizeImports(newContent, mode);
633+
public organizeImports(newContent: string, mode?: ts.OrganizeImportsMode, preferences?: ts.UserPreferences): void {
634+
this.state.verifyOrganizeImports(newContent, mode, preferences);
635635
}
636636
}
637637

Diff for: src/server/protocol.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3516,6 +3516,7 @@ export interface UserPreferences {
35163516
readonly includeInlayFunctionLikeReturnTypeHints?: boolean;
35173517
readonly includeInlayEnumMemberValueHints?: boolean;
35183518
readonly autoImportFileExcludePatterns?: string[];
3519+
readonly organizeImportsIgnoreCase?: "auto" | boolean;
35193520

35203521
/**
35213522
* Indicates whether {@link ReferencesResponseItem.lineText} is supported.

Diff for: src/services/codefixes/importFixes.ts

+47-17
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ import {
118118
skipAlias,
119119
some,
120120
sort,
121+
SortKind,
121122
SourceFile,
122123
stableSort,
123124
startsWith,
@@ -164,15 +165,14 @@ registerCodeFix({
164165
const { errorCode, preferences, sourceFile, span, program } = context;
165166
const info = getFixInfos(context, errorCode, span.start, /*useAutoImportProvider*/ true);
166167
if (!info) return undefined;
167-
const quotePreference = getQuotePreference(sourceFile, preferences);
168168
return info.map(({ fix, symbolName, errorIdentifierText }) => codeActionForFix(
169169
context,
170170
sourceFile,
171171
symbolName,
172172
fix,
173173
/*includeSymbolNameInDescription*/ symbolName !== errorIdentifierText,
174-
quotePreference,
175-
program.getCompilerOptions()));
174+
program.getCompilerOptions(),
175+
preferences));
176176
},
177177
fixIds: [importFixId],
178178
getAllCodeActions: context => {
@@ -358,7 +358,8 @@ function createImportAdderWorker(sourceFile: SourceFile, program: Program, useAu
358358
importClauseOrBindingPattern,
359359
defaultImport,
360360
arrayFrom(namedImports.entries(), ([name, addAsTypeOnly]) => ({ addAsTypeOnly, name })),
361-
compilerOptions);
361+
compilerOptions,
362+
preferences);
362363
});
363364

364365
let newDeclarations: AnyImportOrRequireStatement | readonly AnyImportOrRequireStatement[] | undefined;
@@ -516,7 +517,8 @@ export function getImportCompletionAction(
516517
symbolName,
517518
fix,
518519
/*includeSymbolNameInDescription*/ false,
519-
getQuotePreference(sourceFile, preferences), compilerOptions))
520+
compilerOptions,
521+
preferences))
520522
};
521523
}
522524

@@ -526,7 +528,7 @@ export function getPromoteTypeOnlyCompletionAction(sourceFile: SourceFile, symbo
526528
const symbolName = single(getSymbolNamesToImport(sourceFile, program.getTypeChecker(), symbolToken, compilerOptions));
527529
const fix = getTypeOnlyPromotionFix(sourceFile, symbolToken, symbolName, program);
528530
const includeSymbolNameInDescription = symbolName !== symbolToken.text;
529-
return fix && codeFixActionToCodeAction(codeActionForFix({ host, formatContext, preferences }, sourceFile, symbolName, fix, includeSymbolNameInDescription, QuotePreference.Double, compilerOptions));
531+
return fix && codeFixActionToCodeAction(codeActionForFix({ host, formatContext, preferences }, sourceFile, symbolName, fix, includeSymbolNameInDescription, compilerOptions, preferences));
530532
}
531533

532534
function getImportFixForSymbol(sourceFile: SourceFile, exportInfos: readonly SymbolExportInfo[], moduleSymbol: Symbol, program: Program, useNamespaceInfo: { position: number, symbolName: string } | undefined, isValidTypeOnlyUseSite: boolean, useRequire: boolean, host: LanguageServiceHost, preferences: UserPreferences) {
@@ -1176,14 +1178,15 @@ function getExportEqualsImportKind(importingFile: SourceFile, compilerOptions: C
11761178
return allowSyntheticDefaults ? ImportKind.Default : ImportKind.CommonJS;
11771179
}
11781180

1179-
function codeActionForFix(context: textChanges.TextChangesContext, sourceFile: SourceFile, symbolName: string, fix: ImportFix, includeSymbolNameInDescription: boolean, quotePreference: QuotePreference, compilerOptions: CompilerOptions): CodeFixAction {
1181+
function codeActionForFix(context: textChanges.TextChangesContext, sourceFile: SourceFile, symbolName: string, fix: ImportFix, includeSymbolNameInDescription: boolean, compilerOptions: CompilerOptions, preferences: UserPreferences): CodeFixAction {
11801182
let diag!: DiagnosticAndArguments;
11811183
const changes = textChanges.ChangeTracker.with(context, tracker => {
1182-
diag = codeActionForFixWorker(tracker, sourceFile, symbolName, fix, includeSymbolNameInDescription, quotePreference, compilerOptions);
1184+
diag = codeActionForFixWorker(tracker, sourceFile, symbolName, fix, includeSymbolNameInDescription, compilerOptions, preferences);
11831185
});
11841186
return createCodeFixAction(importFixName, changes, diag, importFixId, Diagnostics.Add_all_missing_imports);
11851187
}
1186-
function codeActionForFixWorker(changes: textChanges.ChangeTracker, sourceFile: SourceFile, symbolName: string, fix: ImportFix, includeSymbolNameInDescription: boolean, quotePreference: QuotePreference, compilerOptions: CompilerOptions): DiagnosticAndArguments {
1188+
function codeActionForFixWorker(changes: textChanges.ChangeTracker, sourceFile: SourceFile, symbolName: string, fix: ImportFix, includeSymbolNameInDescription: boolean, compilerOptions: CompilerOptions, preferences: UserPreferences): DiagnosticAndArguments {
1189+
const quotePreference = getQuotePreference(sourceFile, preferences);
11871190
switch (fix.kind) {
11881191
case ImportFixKind.UseNamespace:
11891192
addNamespaceQualifier(changes, sourceFile, fix);
@@ -1199,7 +1202,8 @@ function codeActionForFixWorker(changes: textChanges.ChangeTracker, sourceFile:
11991202
importClauseOrBindingPattern,
12001203
importKind === ImportKind.Default ? { name: symbolName, addAsTypeOnly } : undefined,
12011204
importKind === ImportKind.Named ? [{ name: symbolName, addAsTypeOnly }] : emptyArray,
1202-
compilerOptions);
1205+
compilerOptions,
1206+
preferences);
12031207
const moduleSpecifierWithoutQuotes = stripQuotes(moduleSpecifier);
12041208
return includeSymbolNameInDescription
12051209
? [Diagnostics.Import_0_from_1, symbolName, moduleSpecifierWithoutQuotes]
@@ -1240,10 +1244,11 @@ function promoteFromTypeOnly(changes: textChanges.ChangeTracker, aliasDeclaratio
12401244
switch (aliasDeclaration.kind) {
12411245
case SyntaxKind.ImportSpecifier:
12421246
if (aliasDeclaration.isTypeOnly) {
1243-
if (aliasDeclaration.parent.elements.length > 1 && OrganizeImports.importSpecifiersAreSorted(aliasDeclaration.parent.elements)) {
1247+
const sortKind = OrganizeImports.detectImportSpecifierSorting(aliasDeclaration.parent.elements);
1248+
if (aliasDeclaration.parent.elements.length > 1 && sortKind) {
12441249
changes.delete(sourceFile, aliasDeclaration);
12451250
const newSpecifier = factory.updateImportSpecifier(aliasDeclaration, /*isTypeOnly*/ false, aliasDeclaration.propertyName, aliasDeclaration.name);
1246-
const insertionIndex = OrganizeImports.getImportSpecifierInsertionIndex(aliasDeclaration.parent.elements, newSpecifier);
1251+
const insertionIndex = OrganizeImports.getImportSpecifierInsertionIndex(aliasDeclaration.parent.elements, newSpecifier, sortKind === SortKind.CaseInsensitive);
12471252
changes.insertImportSpecifierAtIndex(sourceFile, newSpecifier, aliasDeclaration.parent, insertionIndex);
12481253
}
12491254
else {
@@ -1274,7 +1279,7 @@ function promoteFromTypeOnly(changes: textChanges.ChangeTracker, aliasDeclaratio
12741279
if (convertExistingToTypeOnly) {
12751280
const namedImports = tryCast(importClause.namedBindings, isNamedImports);
12761281
if (namedImports && namedImports.elements.length > 1) {
1277-
if (OrganizeImports.importSpecifiersAreSorted(namedImports.elements) &&
1282+
if (OrganizeImports.detectImportSpecifierSorting(namedImports.elements) &&
12781283
aliasDeclaration.kind === SyntaxKind.ImportSpecifier &&
12791284
namedImports.elements.indexOf(aliasDeclaration) !== 0
12801285
) {
@@ -1300,6 +1305,7 @@ function doAddExistingFix(
13001305
defaultImport: Import | undefined,
13011306
namedImports: readonly Import[],
13021307
compilerOptions: CompilerOptions,
1308+
preferences: UserPreferences,
13031309
): void {
13041310
if (clause.kind === SyntaxKind.ObjectBindingPattern) {
13051311
if (defaultImport) {
@@ -1327,21 +1333,45 @@ function doAddExistingFix(
13271333
}
13281334

13291335
if (namedImports.length) {
1336+
// sort case sensitivity:
1337+
// - if the user preference is explicit, use that
1338+
// - otherwise, if there are enough existing import specifiers in this import to detect unambiguously, use that
1339+
// - otherwise, detect from other imports in the file
1340+
let ignoreCaseForSorting: boolean | undefined;
1341+
if (typeof preferences.organizeImportsIgnoreCase === "boolean") {
1342+
ignoreCaseForSorting = preferences.organizeImportsIgnoreCase;
1343+
}
1344+
else if (existingSpecifiers) {
1345+
const targetImportSorting = OrganizeImports.detectImportSpecifierSorting(existingSpecifiers);
1346+
if (targetImportSorting !== SortKind.Both) {
1347+
ignoreCaseForSorting = targetImportSorting === SortKind.CaseInsensitive;
1348+
}
1349+
}
1350+
if (ignoreCaseForSorting === undefined) {
1351+
ignoreCaseForSorting = OrganizeImports.detectSorting(sourceFile) === SortKind.CaseInsensitive;
1352+
}
1353+
13301354
const newSpecifiers = stableSort(
13311355
namedImports.map(namedImport => factory.createImportSpecifier(
13321356
(!clause.isTypeOnly || promoteFromTypeOnly) && needsTypeOnly(namedImport),
13331357
/*propertyName*/ undefined,
13341358
factory.createIdentifier(namedImport.name))),
1335-
OrganizeImports.compareImportOrExportSpecifiers);
1336-
1337-
if (existingSpecifiers?.length && OrganizeImports.importSpecifiersAreSorted(existingSpecifiers)) {
1359+
(s1, s2) => OrganizeImports.compareImportOrExportSpecifiers(s1, s2, ignoreCaseForSorting));
1360+
1361+
// The sorting preference computed earlier may or may not have validated that these particular
1362+
// import specifiers are sorted. If they aren't, `getImportSpecifierInsertionIndex` will return
1363+
// nonsense. So if there are existing specifiers, even if we know the sorting preference, we
1364+
// need to ensure that the existing specifiers are sorted according to the preference in order
1365+
// to do a sorted insertion.
1366+
const specifierSort = existingSpecifiers?.length && OrganizeImports.detectImportSpecifierSorting(existingSpecifiers);
1367+
if (specifierSort && !(ignoreCaseForSorting && specifierSort === SortKind.CaseSensitive)) {
13381368
for (const spec of newSpecifiers) {
13391369
// Organize imports puts type-only import specifiers last, so if we're
13401370
// adding a non-type-only specifier and converting all the other ones to
13411371
// type-only, there's no need to ask for the insertion index - it's 0.
13421372
const insertionIndex = convertExistingToTypeOnly && !spec.isTypeOnly
13431373
? 0
1344-
: OrganizeImports.getImportSpecifierInsertionIndex(existingSpecifiers, spec);
1374+
: OrganizeImports.getImportSpecifierInsertionIndex(existingSpecifiers, spec, ignoreCaseForSorting);
13451375
changes.insertImportSpecifierAtIndex(sourceFile, spec, clause.namedBindings as NamedImports, insertionIndex);
13461376
}
13471377
}

0 commit comments

Comments
 (0)