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). Kept through 2.0.0 so the upgrade is one change, not ' 'two; removed in 3.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. Kept through 2.0.0; removed in 3.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,
  16. bool flat = false,
  17. bool relativeImports = false,
  18. String? libRelativePath,
  19. 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) =>
      line.startsWith('import ') || (sortExports && line.startsWith('export '));

  // 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.
  bool directiveFollows(int index) {
    var i = index;
    while (i < lines.length &&
        (_isIgnorePragma(lines[i]) ||
            (attachComments && _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: 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,
    );
  }

  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);
    emit(flatImports);
    if (flatImports.isNotEmpty && flatExports.isNotEmpty && !noBlankLines) {
      sortedLines.add('');
    }
    emit(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);
}