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