sortImports function
- List<
String> lines, - String packageName,
- bool emojis,
- @Deprecated('Has no effect since 1.4.2: lib/ no longer calls exit(). bin/ checks the ' 'whole project and fails once, so every unsorted file gets reported ' '(import_sorter#87). Kept through 2.0.0 so the upgrade is one change, not ' 'two; removed in 3.0.0.') bool exitIfChanged,
- bool noComments, {
- @Deprecated('Has no effect since 1.4.2; it only ever fed the message the removed ' 'exit() printed. Kept through 2.0.0; removed in 3.0.0.') String? filePath,
- bool noBlankLines = false,
- List<
CustomTier> customTiers = const [], - bool groupProjectByFolder = false,
- bool testImports = false,
- List<
String> testImportPrefixes = TidyConfig.defaultTestImportPrefixes, - bool separateRelativeImports = false,
- bool sortExports = false,
- int groupProjectByFolderDepth = 0,
- bool removeDuplicates = false,
- bool flat = false,
- bool relativeImports = false,
- String? libRelativePath,
- bool attachComments = false,
Sort the imports of a dart file.
Returns ImportSortData containing the sorted file content and whether any changes were made. Pure: it reads nothing, writes nothing, and never terminates the process — what an unsorted file means is the caller's call.
removeDuplicates drops a directive that repeats one already kept, keeping
the first occurrence. It compares text, not meaning, so it only ever folds
away a directive written identically twice — the shape a merge or a
double auto-import produces. Removing an import that is merely unused
is a different question, one that needs a resolved element model;
dart fix --apply --code=unused_import answers it, and the CLI's
--remove-unused runs exactly that before sorting.
flat drops the groups entirely and emits one alphabetical run per
section — dart:, then package:, then relative — which is the order the
directives_ordering lint expects. Grouping options are ignored under it,
since there are no groups left to shape (import_sorter#58, #28).
attachComments keeps a // comment written directly above a directive
with that directive. Off by default: without it such a comment is not part
of the directive, so once the block has been rebuilt it ends up below the
sorted imports, explaining whatever now follows it. A comment above the
first directive is the file's own header either way and stays on top.
relativeImports rewrites package:<packageName>/… URIs as paths relative
to libRelativePath, the file's own location under lib/. Without that
path there is nothing to be relative to, so the rewrite is skipped — as
it is for files outside lib/, which cannot reach it with a relative URI
at all (import_sorter#59).
Implementation
ImportSortData sortImports(
List<String> lines,
String packageName,
bool emojis,
@Deprecated(
'Has no effect since 1.4.2: lib/ no longer calls exit(). bin/ checks the '
'whole project and fails once, so every unsorted file gets reported '
'(import_sorter#87). Kept through 2.0.0 so the upgrade is one change, not '
'two; removed in 3.0.0.',
)
bool exitIfChanged,
bool noComments, {
@Deprecated(
'Has no effect since 1.4.2; it only ever fed the message the removed '
'exit() printed. Kept through 2.0.0; removed in 3.0.0.',
)
String? filePath,
bool noBlankLines = false,
List<CustomTier> customTiers = const [],
bool groupProjectByFolder = false,
bool testImports = false,
List<String> testImportPrefixes = TidyConfig.defaultTestImportPrefixes,
bool separateRelativeImports = false,
bool sortExports = false,
int groupProjectByFolderDepth = 0,
bool removeDuplicates = false,
bool flat = false,
bool relativeImports = false,
String? libRelativePath,
bool attachComments = false,
}) {
// Asking for a folder depth is asking for folder grouping; requiring both
// options only creates a way to set the depth and see nothing happen.
final groupByFolder = groupProjectByFolder || groupProjectByFolderDepth > 0;
String groupComment(String name, String emoji, String noun) =>
'//${emojis ? ' $emoji ' : ' '}$name $noun:';
String tierComment(CustomTier tier) =>
'//${emojis ? ' 🧩 ' : ' '}${tier.name}';
// Every header we have ever emitted, so a re-run strips it instead of
// stacking a second one on top.
final strippable = <String>{'// 📱 Flutter imports:'};
for (final noun in const ['imports', 'exports']) {
for (final label in _groupLabels) {
strippable
..add('// ${label[0]} $noun:')
..add('// ${label[1]} ${label[0]} $noun:');
}
}
for (final tier in customTiers) {
strippable
..add('// ${tier.name}')
..add('// 🧩 ${tier.name}');
}
final beforeLines = <String>[];
final afterLines = <String>[];
final imports = _Buckets(customTiers);
final exports = _Buckets(customTiers);
// Under [flat] there are no groups, so the buckets stay empty and everything
// lands in one run per directive kind.
final flatImports = <_Directive>[];
final flatExports = <_Directive>[];
bool startsDirective(String line) =>
_opensImport.hasMatch(line) ||
(sortExports && _opensExport.hasMatch(line));
// Whether a directive begins at [index], looking past the comment lines
// that belong to it. Decides whether a header line above is ours — and a
// header followed by a note about the import below it is still ours.
//
// The lookahead deliberately ignores [attachComments]. A plain `//` comment
// sitting between one of our own headers and a directive is a shape only a
// previous `attachComments: true` run writes, so refusing to look past it
// when the option is off cost that block its header: the old one stayed
// behind as body text, a second copy was regenerated above the import, and
// the comment was orphaned under the duplicate. The next run read that as
// already sorted, so the file never healed — switching the option off once
// corrupted the block permanently.
bool directiveFollows(int index) {
var i = index;
while (i < lines.length &&
(_isIgnorePragma(lines[i]) || _isAttachedComment(lines[i]))) {
i++;
}
return i < lines.length && startsDirective(lines[i]);
}
bool noDirectives() =>
imports.isEmpty &&
exports.isEmpty &&
flatImports.isEmpty &&
flatExports.isEmpty;
void classify(_Directive directive, {required bool isExport}) {
if (flat) {
(isExport ? flatExports : flatImports).add(directive);
return;
}
final bucket = isExport ? exports : imports;
final uri = directive.uri;
if (uri.startsWith('dart:')) {
bucket.dart.add(directive);
} else if (uri.startsWith('package:flutter/')) {
bucket.flutter.add(directive);
} else if (uri.startsWith('package:$packageName/')) {
if (testImports && _isTestDouble(uri, testImportPrefixes)) {
bucket.testDoublePackageForm.add(directive);
} else {
bucket.projectPackageForm.add(directive);
}
} else if (uri.startsWith('package:')) {
final tier = _matchTier(directive.code, customTiers);
if (tier != null) {
bucket.tiers[tier]!.add(directive);
} else {
bucket.package.add(directive);
}
} else if (testImports && _isTestDouble(uri, testImportPrefixes)) {
bucket.testDoubleRelative.add(directive);
} else {
bucket.projectRelative.add(directive);
}
}
// `package:<self>/…` rewritten as a path relative to this file. Left alone
// when the option is off, when the file's own location is unknown, or when
// the URI points anywhere else — another package's `package:` URI has no
// relative form from here.
_Directive relativize(_Directive directive) {
final from = libRelativePath;
if (!relativeImports || from == null) return directive;
const scheme = 'package:';
final prefix = '$scheme$packageName/';
if (!directive.uri.startsWith(prefix)) return directive;
final relative =
_relativePath(from, directive.uri.substring(prefix.length));
if (relative == null) return directive;
return _Directive(
directive.leading,
[
directive.lines.first.replaceFirst(directive.uri, relative),
...directive.lines.skip(1),
],
relative,
directive.order,
);
}
// Signatures of the directives kept so far, for [removeDuplicates].
final seen = <String>{};
var duplicatesRemoved = 0;
final scanner = _SourceScanner();
var order = 0;
var index = 0;
while (index < lines.length) {
final line = lines[index];
// Only a line that *begins* in executable code can be a directive or a
// header of ours. Inside a string literal or a `/* */` block it is text,
// and moving it would change what the file means.
if (scanner.startsInCode) {
// A header we wrote on an earlier run: drop it, the emitter re-adds it.
if (strippable.contains(line) && directiveFollows(index + 1)) {
scanner.consume(line);
index++;
continue;
}
// A comment written directly above a directive explains it, so it moves
// with it. `// ignore:` has to, or the suppression is switched off; a
// plain note has to as well, or it ends up below the sorted block,
// explaining whatever now follows it.
//
// Only once the block has started: a comment above the *first* directive
// is the file's own header — a licence, a `// Dart imports:` of ours —
// and belongs at the top, where it was.
// A comment above the *first* directive is the file's own header — a
// licence, a `// Dart imports:` of ours — so it is never attached.
final attaching = attachComments && !noDirectives();
var start = index;
while (start < lines.length &&
(_isIgnorePragma(lines[start]) ||
(attaching && _isAttachedComment(lines[start])))) {
start++;
}
if (start < lines.length && startsDirective(lines[start])) {
final span = _scanDirective(lines, start);
if (span > 0) {
final body = lines.sublist(start, start + span);
// The first URI anywhere in the body, not on the first line only:
// `import` followed by the URI on the next line is legal Dart.
final uri = _directiveTargets(body).firstOrNull;
if (uri != null) {
// Rewrite first: a `package:` URI and its relative form are the
// same import, and only look like duplicates once both are
// written the same way.
final directive = relativize(
_Directive(lines.sublist(index, start), body, uri, order++),
);
if (removeDuplicates && !seen.add(directive.signature)) {
duplicatesRemoved++;
} else {
classify(directive, isExport: _opensExport.hasMatch(body.first));
}
// A directive can carry a `/*` or a string of its own, so the
// scanner has to walk the lines the loop skips over.
for (var i = index; i < start + span; i++) {
scanner.consume(lines[i]);
}
index = start + span;
continue;
}
}
}
}
(noDirectives() ? beforeLines : afterLines).add(line);
scanner.consume(line);
index++;
}
if (noDirectives()) {
var joinedLines = lines.join('\n');
if (!joinedLines.endsWith('\n')) {
joinedLines += '\n';
}
return ImportSortData(joinedLines, false);
}
if (beforeLines.isNotEmpty && beforeLines.last.trim().isEmpty) {
beforeLines.removeLast();
}
final sortedLines = <String>[...beforeLines];
if (beforeLines.isNotEmpty) {
sortedLines.add('');
}
var hasPrevious = false;
void addSeparator() {
if (!noBlankLines && hasPrevious) sortedLines.add('');
}
void emit(List<_Directive> directives) {
for (final directive in directives) {
sortedLines
..addAll(directive.leading)
..addAll(directive.lines);
}
}
void emitGroup(List<_Directive> directives, String comment) {
if (directives.isEmpty) return;
addSeparator();
if (!noComments) sortedLines.add(comment);
_sortByUri(directives);
emit(directives);
hasPrevious = true;
}
// Since Dart 3.13 `dart format` puts a blank line between the `package:` and
// relative sections. Without one, the two tools undo each other on every run
// (issue #1), so [separateRelativeImports] emits it up front. Never fires
// when blank lines are switched off.
bool separateBefore(
List<_Directive> packageForm,
List<_Directive> relative,
) =>
separateRelativeImports &&
!noBlankLines &&
packageForm.isNotEmpty &&
relative.isNotEmpty;
// Emits one group split into a package-form and a relative-form half that
// share a single header: the project group, and the test-double group that
// mirrors it.
void emitSplitGroup(
List<_Directive> packageForm,
List<_Directive> relative,
String comment, {
required bool byFolder,
}) {
if (packageForm.isEmpty && relative.isEmpty) return;
addSeparator();
if (!noComments) sortedLines.add(comment);
_sortByUri(packageForm);
_sortByUri(relative);
if (byFolder && !noBlankLines) {
// Folder grouping already breaks at the package-form/relative-form
// boundary (a relative URI can never start with `package:`), so the two
// features never stack up two blank lines.
String? previousKey;
for (final directive in [...packageForm, ...relative]) {
final key = _folderKey(directive.uri, groupProjectByFolderDepth);
if (previousKey != null && key != previousKey) sortedLines.add('');
sortedLines
..addAll(directive.leading)
..addAll(directive.lines);
previousKey = key;
}
} else {
emit(packageForm);
if (separateBefore(packageForm, relative)) sortedLines.add('');
emit(relative);
}
hasPrevious = true;
}
void emitBlock(_Buckets bucket, String noun) {
if (bucket.isEmpty) return;
emitGroup(bucket.dart, groupComment('Dart', '🎯', noun));
emitGroup(bucket.flutter, groupComment('Flutter', '🐦', noun));
emitGroup(bucket.package, groupComment('Package', '📦', noun));
for (final tier in customTiers) {
emitGroup(bucket.tiers[tier]!, tierComment(tier));
}
emitSplitGroup(
bucket.projectPackageForm,
bucket.projectRelative,
groupComment('Project', '🌎', noun),
byFolder: groupByFolder,
);
emitSplitGroup(
bucket.testDoublePackageForm,
bucket.testDoubleRelative,
groupComment('Test', '🧪', noun),
byFolder: false,
);
}
// One run per section, with a blank line where the section changes — which
// is exactly where `dart format` 3.13+ puts one. Emitting none left the
// formatter adding all three and the next run taking them away again, so
// `--flat` fought the formatter on every single run (issue #1, in the one
// mode that had no separator at all). `--no-blank-lines` still gives the
// tight run: the lint reads order, not spacing.
void emitBySection(List<_Directive> directives) {
int? previous;
for (final directive in directives) {
final section = _section(directive.uri);
if (previous != null && section != previous && !noBlankLines) {
sortedLines.add('');
}
sortedLines
..addAll(directive.leading)
..addAll(directive.lines);
previous = section;
}
}
if (flat) {
// `directives_ordering` wants one alphabetical run per section, exports
// in their own block below the imports. No headers: a comment between two
// runs the lint considers one section would be a lie about the structure.
_sortFlatly(flatImports);
_sortFlatly(flatExports);
emitBySection(flatImports);
if (flatImports.isNotEmpty && flatExports.isNotEmpty && !noBlankLines) {
sortedLines.add('');
}
emitBySection(flatExports);
} else {
emitBlock(imports, 'imports');
emitBlock(exports, 'exports');
}
// Everything below the directive block, with the blank lines that separated
// it from the directives dropped — the emitter re-adds exactly one.
final trailing = <String>[];
var addedCode = false;
for (final line in afterLines) {
if (line != '') {
trailing.add(line);
addedCode = true;
} else if (addedCode) {
trailing.add(line);
}
}
// A barrel file ends on its last directive. Emitting the separator anyway
// left a blank line below it, which `dart format` then strips right back
// out — so the two tools undid each other on every run (issue #6).
if (trailing.isNotEmpty) {
sortedLines
..add('')
..addAll(trailing);
}
sortedLines.add('');
final sortedFile = sortedLines.join('\n');
final original = '${lines.join('\n')}\n';
if (original == sortedFile) {
return ImportSortData(original, false);
}
return ImportSortData(sortedFile, true, duplicatesRemoved: duplicatesRemoved);
}