/*
* Copyright 2016 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.googlejavaformat.java;
import static com.google.common.collect.Iterables.getLast;
import static com.google.common.primitives.Booleans.trueFirst;
import com.google.common.base.CharMatcher;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.googlejavaformat.Newlines;
import com.google.googlejavaformat.java.JavaFormatterOptions.Style;
import com.google.googlejavaformat.java.JavaInput.Tok;
import com.sun.tools.javac.parser.Tokens.TokenKind;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.stream.Stream;
/** Orders imports in Java source code. */
public class ImportOrderer {
private static final Splitter DOT_SPLITTER = Splitter.on('.');
/**
* Reorder the inputs in {@code text}, a complete Java program. On success, another complete Java
* program is returned, which is the same as the original except the imports are in order.
*
* @throws FormatterException if the input could not be parsed.
*/
public static String reorderImports(String text, Style style) throws FormatterException {
ImmutableList<Tok> toks = JavaInput.buildToks(text, CLASS_START);
return new ImportOrderer(text, toks, style).reorderImports();
}
/**
* Reorder the inputs in {@code text}, a complete Java program, in Google style. On success,
* another complete Java program is returned, which is the same as the original except the imports
* are in order.
*
* @deprecated Use {@link #reorderImports(String, Style)} instead
* @throws FormatterException if the input could not be parsed.
*/
@Deprecated
public static String reorderImports(String text) throws FormatterException {
return reorderImports(text, Style.GOOGLE);
}
private String reorderImports() throws FormatterException {
int firstImportStart;
Optional<Integer> maybeFirstImport = findIdentifier(0, IMPORT_OR_CLASS_START);
if (!maybeFirstImport.isPresent() || !tokenAt(maybeFirstImport.get()).equals("import")) {
// No imports, so nothing to do.
return text;
}
firstImportStart = maybeFirstImport.get();
int unindentedFirstImportStart = unindent(firstImportStart);
ImportsAndIndex imports = scanImports(firstImportStart);
int afterLastImport = imports.index;
// Make sure there are no more imports before the next class (etc) definition.
Optional<Integer> maybeLaterImport = findIdentifier(afterLastImport, IMPORT_OR_CLASS_START);
if (maybeLaterImport.isPresent() && tokenAt(maybeLaterImport.get()).equals("import")) {
throw new FormatterException("Imports not contiguous (perhaps a comment separates them?)");
}
StringBuilder result = new StringBuilder();
String prefix = tokString(0, unindentedFirstImportStart);
result.append(prefix);
if (!prefix.isEmpty() && Newlines.getLineEnding(prefix) == null) {
result.append(lineSeparator).append(lineSeparator);
}
result.append(reorderedImportsString(imports.imports));
List<String> tail = new ArrayList<>();
tail.add(CharMatcher.whitespace().trimLeadingFrom(tokString(afterLastImport, toks.size())));
if (!toks.isEmpty()) {
Tok lastTok = getLast(toks);
int tailStart = lastTok.getPosition() + lastTok.length();
tail.add(text.substring(tailStart));
}
if (tail.stream().anyMatch(s -> !s.isEmpty())) {
result.append(lineSeparator);
tail.forEach(result::append);
}
return result.toString();
}
/**
* {@link TokenKind}s that indicate the start of a type definition. We use this to avoid scanning
* the whole file, since we know that imports must precede any type definition.
*/
private static final ImmutableSet<TokenKind> CLASS_START =
ImmutableSet.of(TokenKind.CLASS, TokenKind.INTERFACE, TokenKind.ENUM);
/**
* We use this set to find the first import, and again to check that there are no imports after
* the place we stopped gathering them. An annotation definition ({@code @interface}) is two
* tokens, the second which is {@code interface}, so we don't need a separate entry for that.
*/
private static final ImmutableSet<String> IMPORT_OR_CLASS_START =
ImmutableSet.of("import", "class", "interface", "enum");
/**
* A {@link Comparator} that orders {@link Import}s by Google Style, defined at
* https://google.github.io/styleguide/javaguide.html#s3.3.3-import-ordering-and-spacing.
*/
private static final Comparator<Import> GOOGLE_IMPORT_COMPARATOR =
Comparator.comparing(Import::isStatic, trueFirst()).thenComparing(Import::imported);
/**
* A {@link Comparator} that orders {@link Import}s by AOSP Style, defined at
* https://source.android.com/setup/contribute/code-style#order-import-statements and implemented
* in IntelliJ at
* https://android.googlesource.com/platform/development/+/master/ide/intellij/codestyles/AndroidStyle.xml.
*/
private static final Comparator<Import> AOSP_IMPORT_COMPARATOR =
Comparator.comparing(Import::isStatic, trueFirst())
.thenComparing(Import::isAndroid, trueFirst())
.thenComparing(Import::isThirdParty, trueFirst())
.thenComparing(Import::isChromium, trueFirst())
.thenComparing(Import::isJava, trueFirst())
.thenComparing(Import::imported);
/**
* Determines whether to insert a blank line between the {@code prev} and {@code curr} {@link
* Import}s based on Google style.
*/
private static boolean shouldInsertBlankLineGoogle(Import prev, Import curr) {
return prev.isStatic() && !curr.isStatic();
}
/**
* Determines whether to insert a blank line between the {@code prev} and {@code curr} {@link
* Import}s based on AOSP style.
*/
private static boolean shouldInsertBlankLineAosp(Import prev, Import curr) {
if (prev.isStatic() && !curr.isStatic()) {
return true;
}
// insert blank line between "com.android" from "com.anythingelse"
if (prev.isAndroid() && !curr.isAndroid()) {
return true;
}
if (prev.isChromium() != curr.isChromium()) {
return true;
}
return !prev.topLevel().equals(curr.topLevel());
}
private final String text;
private final ImmutableList<Tok> toks;
private final String lineSeparator;
private final Comparator<Import> importComparator;
private final BiFunction<Import, Import, Boolean> shouldInsertBlankLineFn;
private ImportOrderer(String text, ImmutableList<Tok> toks, Style style) {
this.text = text;
this.toks = toks;
this.lineSeparator = Newlines.guessLineSeparator(text);
if (style.equals(Style.GOOGLE)) {
this.importComparator = GOOGLE_IMPORT_COMPARATOR;
this.shouldInsertBlankLineFn = ImportOrderer::shouldInsertBlankLineGoogle;
} else if (style.equals(Style.AOSP)) {
this.importComparator = AOSP_IMPORT_COMPARATOR;
this.shouldInsertBlankLineFn = ImportOrderer::shouldInsertBlankLineAosp;
} else {
throw new IllegalArgumentException("Unsupported code style: " + style);
}
}
/** An import statement. */
class Import {
private final String imported;
private final boolean isStatic;
private final String trailing;
Import(String imported, String trailing, boolean isStatic) {
this.imported = imported;
this.trailing = trailing;
this.isStatic = isStatic;
}
/** The name being imported, for example {@code java.util.List}. */
String imported() {
return imported;
}
/** True if this is {@code import static}. */
boolean isStatic() {
return isStatic;
}
/** The top-level package of the import. */
String topLevel() {
return DOT_SPLITTER.split(imported()).iterator().next();
}
/** True if this is an Android import per AOSP style. */
boolean isAndroid() {
return Stream.of("android.", "androidx.", "dalvik.", "libcore.", "com.android.")
.anyMatch(imported::startsWith);
}
/** True if this is a Java import per AOSP style. */
boolean isJava() {
switch (topLevel()) {
case "java":
case "javax":
return true;
default:
return false;
}
}
/** True if this is a Chromium import per AOSP style. */
boolean isChromium() {
return Stream.of("org.chromium.", "com.google.android.apps.chrome")
.anyMatch(imported::startsWith);
}
/**
* The {@code //} comment lines after the final {@code ;}, up to and including the line
* terminator of the last one. Note: In case two imports were separated by a space (which is
* disallowed by the style guide), the trailing whitespace of the first import does not include
* a line terminator.
*/
String trailing() {
return trailing;
}
/** True if this is a third-party import per AOSP style. */
public boolean isThirdParty() {
return !(isAndroid() || isJava() || isChromium());
}
// One or multiple lines, the import itself and following comments, including the line
// terminator.
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("import ");
if (isStatic()) {
sb.append("static ");
}
sb.append(imported()).append(';');
if (trailing().trim().isEmpty()) {
sb.append(lineSeparator);
} else {
sb.append(trailing());
}
return sb.toString();
}
}
private String tokString(int start, int end) {
StringBuilder sb = new StringBuilder();
for (int i = start; i < end; i++) {
sb.append(toks.get(i).getOriginalText());
}
return sb.toString();
}
private static class ImportsAndIndex {
final ImmutableSortedSet<Import> imports;
final int index;
ImportsAndIndex(ImmutableSortedSet<Import> imports, int index) {
this.imports = imports;
this.index = index;
}
}
/**
* Scans a sequence of import lines. The parsing uses this approximate grammar:
*
* <pre>{@code
* <imports> -> (<end-of-line> | <import>)*
* <import> -> "import" <whitespace> ("static" <whitespace>)?
* <identifier> ("." <identifier>)* ("." "*")? <whitespace>? ";"
* <whitespace>? <end-of-line>? (<line-comment> <end-of-line>)*
* }</pre>
*
* @param i the index to start parsing at.
* @return the result of parsing the imports.
* @throws FormatterException if imports could not parsed according to the grammar.
*/
private ImportsAndIndex scanImports(int i) throws FormatterException {
int afterLastImport = i;
ImmutableSortedSet.Builder<Import> imports = ImmutableSortedSet.orderedBy(importComparator);
// JavaInput.buildToks appends a zero-width EOF token after all tokens. It won't match any
// of our tests here and protects us from running off the end of the toks list. Since it is
// zero-width it doesn't matter if we include it in our string concatenation at the end.
while (i < toks.size() && tokenAt(i).equals("import")) {
i++;
if (isSpaceToken(i)) {
i++;
}
boolean isStatic = tokenAt(i).equals("static");
if (isStatic) {
i++;
if (isSpaceToken(i)) {
i++;
}
}
if (!isIdentifierToken(i)) {
throw new FormatterException("Unexpected token after import: " + tokenAt(i));
}
StringAndIndex imported = scanImported(i);
String importedName = imported.string;
i = imported.index;
if (isSpaceToken(i)) {
i++;
}
if (!tokenAt(i).equals(";")) {
throw new FormatterException("Expected ; after import");
}
while (tokenAt(i).equals(";")) {
// Extra semicolons are not allowed by the JLS but are accepted by javac.
i++;
}
StringBuilder trailing = new StringBuilder();
if (isSpaceToken(i)) {
trailing.append(tokenAt(i));
i++;
}
if (isNewlineToken(i)) {
trailing.append(tokenAt(i));
i++;
}
// Gather (if any) all single line comments and accompanied line terminators following this
// import
while (isSlashSlashCommentToken(i)) {
trailing.append(tokenAt(i));
i++;
if (isNewlineToken(i)) {
trailing.append(tokenAt(i));
i++;
}
}
while (tokenAt(i).equals(";")) {
// Extra semicolons are not allowed by the JLS but are accepted by javac.
i++;
}
imports.add(new Import(importedName, trailing.toString(), isStatic));
// Remember the position just after the import we just saw, before skipping blank lines.
// If the next thing after the blank lines is not another import then we don't want to
// include those blank lines in the text to be replaced.
afterLastImport = i;
while (isNewlineToken(i) || isSpaceToken(i)) {
i++;
}
}
return new ImportsAndIndex(imports.build(), afterLastImport);
}
// Produces the sorted output based on the imports we have scanned.
private String reorderedImportsString(ImmutableSortedSet<Import> imports) {
Preconditions.checkArgument(!imports.isEmpty(), "imports");
// Pretend that the first import was preceded by another import of the same kind, so we don't
// insert a newline there.
Import prevImport = imports.iterator().next();
StringBuilder sb = new StringBuilder();
for (Import currImport : imports) {
if (shouldInsertBlankLineFn.apply(prevImport, currImport)) {
// Blank line between static and non-static imports.
sb.append(lineSeparator);
}
sb.append(currImport);
prevImport = currImport;
}
return sb.toString();
}
private static class StringAndIndex {
private final String string;
private final int index;
StringAndIndex(String string, int index) {
this.string = string;
this.index = index;
}
}
/**
* Scans the imported thing, the dot-separated name that comes after import [static] and before
* the semicolon. We don't allow spaces inside the dot-separated name. Wildcard imports are
* supported: if the input is {@code import java.util.*;} then the returned string will be {@code
* java.util.*}.
*
* @param start the index of the start of the identifier. If the import is {@code import
* java.util.List;} then this index points to the token {@code java}.
* @return the parsed import ({@code java.util.List} in the example) and the index of the first
* token after the imported thing ({@code ;} in the example).
* @throws FormatterException if the imported name could not be parsed.
*/
private StringAndIndex scanImported(int start) throws FormatterException {
int i = start;
StringBuilder imported = new StringBuilder();
// At the start of each iteration of this loop, i points to an identifier.
// On exit from the loop, i points to a token after an identifier or after *.
while (true) {
Preconditions.checkState(isIdentifierToken(i));
imported.append(tokenAt(i));
i++;
if (!tokenAt(i).equals(".")) {
return new StringAndIndex(imported.toString(), i);
}
imported.append('.');
i++;
if (tokenAt(i).equals("*")) {
imported.append('*');
return new StringAndIndex(imported.toString(), i + 1);
} else if (!isIdentifierToken(i)) {
throw new FormatterException("Could not parse imported name, at: " + tokenAt(i));
}
}
}
/**
* Returns the index of the first place where one of the given identifiers occurs, or {@code
* Optional.empty()} if there is none.
*
* @param start the index to start looking at
* @param identifiers the identifiers to look for
*/
private Optional<Integer> findIdentifier(int start, ImmutableSet<String> identifiers) {
for (int i = start; i < toks.size(); i++) {
if (isIdentifierToken(i)) {
String id = tokenAt(i);
if (identifiers.contains(id)) {
return Optional.of(i);
}
}
}
return Optional.empty();
}
/** Returns the given token, or the preceding token if it is a whitespace token. */
private int unindent(int i) {
if (i > 0 && isSpaceToken(i - 1)) {
return i - 1;
} else {
return i;
}
}
private String tokenAt(int i) {
return toks.get(i).getOriginalText();
}
private boolean isIdentifierToken(int i) {
String s = tokenAt(i);
return !s.isEmpty() && Character.isJavaIdentifierStart(s.codePointAt(0));
}
private boolean isSpaceToken(int i) {
String s = tokenAt(i);
if (s.isEmpty()) {
return false;
} else {
return " \t\f".indexOf(s.codePointAt(0)) >= 0;
}
}
private boolean isSlashSlashCommentToken(int i) {
return toks.get(i).isSlashSlashComment();
}
private boolean isNewlineToken(int i) {
return toks.get(i).isNewline();
}
}