sortImports function

ImportSortData sortImports(
  1. List<String> lines,
  2. String packageName,
  3. bool emojis,
  4. @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). Will be removed in 2.0.0.') bool exitIfChanged,
  5. bool noComments, {
  6. @Deprecated('Has no effect since 1.4.2; it only ever fed the message the removed ' 'exit() printed. Will be removed in 2.0.0.') String? filePath,
  7. bool noBlankLines = false,
  8. List<CustomTier> customTiers = const [],
  9. bool groupProjectByFolder = false,
  10. bool testImports = false,
  11. List<String> testImportPrefixes = TidyConfig.defaultTestImportPrefixes,
  12. bool separateRelativeImports = false,
  13. bool sortExports = false,
  14. int groupProjectByFolderDepth = 0,
  15. bool removeDuplicates = 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.

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). Will be removed in 2.0.0.',
  )
  bool exitIfChanged,
  bool noComments, {
  @Deprecated(
    'Has no effect since 1.4.2; it only ever fed the message the removed '
    'exit() printed. Will be removed in 2.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,
}) {
  // 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);

  bool startsDirective(String line) =>
      line.startsWith('import ') || (sortExports && line.startsWith('export '));

  // Whether a directive begins at [index], looking past any `// ignore:`
  // pragmas that belong to it. Decides whether a header line above is ours.
  bool directiveFollows(int index) {
    var i = index;
    while (i < lines.length && _isIgnorePragma(lines[i])) {
      i++;
    }
    return i < lines.length && startsDirective(lines[i]);
  }

  bool noDirectives() => imports.isEmpty && exports.isEmpty;

  void classify(_Directive directive, {required bool isExport}) {
    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);
    }
  }

  // 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;
      }

      // `// ignore:` suppresses a lint on the line below it, so it is part of
      // the directive that follows โ€” when one actually follows.
      var start = index;
      while (start < lines.length && _isIgnorePragma(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);
          final uri = _directiveUri(body.first);
          if (uri != null) {
            final directive =
                _Directive(lines.sublist(index, start), body, uri, order++);
            if (removeDuplicates && !seen.add(directive.signature)) {
              duplicatesRemoved++;
            } else {
              classify(
                directive,
                isExport: body.first.startsWith('export '),
              );
            }
            // 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,
    );
  }

  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);
}