1616
1717package com .palantir .javaformat .java ;
1818
19+ import static java .lang .Math .max ;
20+ import static java .nio .charset .StandardCharsets .UTF_8 ;
21+
1922import com .google .common .base .CharMatcher ;
2023import com .google .common .collect .HashMultimap ;
24+ import com .google .common .collect .ImmutableList ;
2125import com .google .common .collect .Multimap ;
2226import com .google .common .collect .Range ;
2327import com .google .common .collect .RangeMap ;
3640import com .sun .source .util .TreePathScanner ;
3741import com .sun .source .util .TreeScanner ;
3842import com .sun .tools .javac .api .JavacTrees ;
43+ import com .sun .tools .javac .file .JavacFileManager ;
44+ import com .sun .tools .javac .parser .ParserFactory ;
3945import com .sun .tools .javac .tree .DCTree ;
4046import com .sun .tools .javac .tree .DCTree .DCReference ;
4147import com .sun .tools .javac .tree .JCTree ;
4248import com .sun .tools .javac .tree .JCTree .JCCompilationUnit ;
4349import com .sun .tools .javac .tree .JCTree .JCFieldAccess ;
44- import com .sun .tools .javac .tree .JCTree .JCIdent ;
4550import com .sun .tools .javac .tree .JCTree .JCImport ;
4651import com .sun .tools .javac .util .Context ;
52+ import com .sun .tools .javac .util .Log ;
4753import com .sun .tools .javac .util .Options ;
54+ import java .io .IOError ;
55+ import java .io .IOException ;
4856import java .lang .reflect .Method ;
57+ import java .net .URI ;
4958import java .util .LinkedHashSet ;
5059import java .util .List ;
5160import java .util .Map ;
5261import java .util .Set ;
62+ import javax .tools .DiagnosticCollector ;
63+ import javax .tools .DiagnosticListener ;
64+ import javax .tools .JavaFileObject ;
65+ import javax .tools .SimpleJavaFileObject ;
66+ import javax .tools .StandardLocation ;
5367
5468/**
5569 * Removes unused imports from a source file. Imports that are only used in javadoc are also removed, and the references
@@ -76,15 +90,12 @@ public class RemoveUnusedImports {
7690 private static final class UnusedImportScanner extends TreePathScanner <Void , Void > {
7791
7892 private final Set <String > usedNames = new LinkedHashSet <>();
79-
8093 private final Multimap <String , Range <Integer >> usedInJavadoc = HashMultimap .create ();
81-
82- final JavacTrees trees ;
83- final DocTreeScanner docTreeSymbolScanner ;
94+ private final DocTreeScanner docTreeSymbolScanner = new DocTreeScanner ();
95+ private final JavacTrees trees ;
8496
8597 private UnusedImportScanner (JavacTrees trees ) {
8698 this .trees = trees ;
87- docTreeSymbolScanner = new DocTreeScanner ();
8899 }
89100
90101 /** Skip the imports themselves when checking for usage. */
@@ -202,21 +213,50 @@ public Void visitIdentifier(IdentifierTree node, Void aVoid) {
202213 }
203214 }
204215
205- public static String removeUnusedImports (final String contents ) throws FormatterException {
216+ public static String removeUnusedImports (final String contents ) {
206217 Context context = new Context ();
207218 JCCompilationUnit unit = parse (context , contents );
208- if (unit == null ) {
209- // error handling is done during formatting
210- return contents ;
211- }
212219 UnusedImportScanner scanner = new UnusedImportScanner (JavacTrees .instance (context ));
213220 scanner .scan (unit , null );
214- return applyReplacements (contents , buildReplacements (contents , unit , scanner .usedNames , scanner .usedInJavadoc ));
221+ String s = applyReplacements (
222+ contents , buildReplacements (contents , unit , scanner .usedNames , scanner .usedInJavadoc ));
223+
224+ // Normalize newlines while preserving important blank lines
225+ String sep = Newlines .guessLineSeparator (contents );
226+
227+ // Ensure exactly one blank line after package declaration
228+ s = s .replaceAll ("(?m)^(package .+)" + sep + "\\ s+" + sep , "$1" + sep + sep );
229+
230+ // Ensure exactly one blank line between last import and class declaration
231+ s = s .replaceAll ("(?m)^(import .+)" + sep + "\\ s+" + sep + "(?=class|interface|enum|record)" , "$1" + sep + sep );
232+
233+ // Remove multiple blank lines elsewhere in imports section
234+ s = s .replaceAll ("(?m)^(import .+)" + sep + "\\ s+" + sep + "(?=import)" , "$1" + sep );
235+
236+ return s ;
215237 }
216238
217- private static JCCompilationUnit parse (Context context , String javaInput ) throws FormatterException {
239+ private static JCCompilationUnit parse (Context context , String javaInput ) {
240+ context .put (DiagnosticListener .class , new DiagnosticCollector <JavaFileObject >());
241+ Options .instance (context ).put ("--enable-preview" , "true" );
218242 Options .instance (context ).put ("allowStringFolding" , "false" );
219- return Formatter .parseJcCompilationUnit (context , javaInput );
243+ try (JavacFileManager fileManager = new JavacFileManager (context , true , UTF_8 )) {
244+ fileManager .setLocation (StandardLocation .PLATFORM_CLASS_PATH , ImmutableList .of ());
245+ } catch (IOException e ) {
246+ throw new IOError (e );
247+ }
248+ SimpleJavaFileObject source = new SimpleJavaFileObject (URI .create ("source" ), JavaFileObject .Kind .SOURCE ) {
249+ @ Override
250+ public CharSequence getCharContent (boolean ignoreEncodingErrors ) {
251+ return javaInput ;
252+ }
253+ };
254+ Log .instance (context ).useSource (source );
255+ JCCompilationUnit unit = ParserFactory .instance (context )
256+ .newParser (javaInput , true , true , true )
257+ .parseCompilationUnit ();
258+ unit .sourcefile = source ;
259+ return unit ;
220260 }
221261
222262 /** Construct replacements to fix unused imports. */
@@ -226,63 +266,80 @@ private static RangeMap<Integer, String> buildReplacements(
226266 Set <String > usedNames ,
227267 Multimap <String , Range <Integer >> usedInJavadoc ) {
228268 RangeMap <Integer , String > replacements = TreeRangeMap .create ();
229- for (JCImport importTree : unit .getImports ()) {
230- String simpleName = getSimpleName (importTree );
269+ int size = unit .getImports ().size ();
270+ JCTree lastImport = size > 0 ? unit .getImports ().get (size - 1 ) : null ;
271+ for (JCTree importTree : unit .getImports ()) {
272+ String simpleName =
273+ getQualifiedIdentifier (importTree ).getIdentifier ().toString ();
231274 if (!isUnused (unit , usedNames , usedInJavadoc , importTree , simpleName )) {
232275 continue ;
233276 }
234277 // delete the import
235278 int endPosition = importTree .getEndPosition (unit .endPositions );
236- endPosition = Math . max (CharMatcher .isNot (' ' ).indexIn (contents , endPosition ), endPosition );
279+ endPosition = max (CharMatcher .isNot (' ' ).indexIn (contents , endPosition ), endPosition );
237280 String sep = Newlines .guessLineSeparator (contents );
281+
282+ // Check if there's an empty line after this import
283+ boolean hasEmptyLineAfter = false ;
284+ if (endPosition + sep .length () * 2 <= contents .length ()) {
285+ String nextTwoLines = contents .substring (endPosition , endPosition + sep .length () * 2 );
286+ hasEmptyLineAfter = nextTwoLines .equals (sep + sep );
287+ }
288+
238289 if (endPosition + sep .length () < contents .length ()
239290 && contents .subSequence (endPosition , endPosition + sep .length ())
240291 .toString ()
241292 .equals (sep )) {
242293 endPosition += sep .length ();
243294 }
295+
296+ // If this isn't the last import and there's an empty line after, preserve it
297+ if ((size == 1 || importTree != lastImport ) && !hasEmptyLineAfter ) {
298+ while (endPosition + sep .length () <= contents .length ()
299+ && contents .regionMatches (endPosition , sep , 0 , sep .length ())) {
300+ endPosition += sep .length ();
301+ }
302+ }
244303 replacements .put (Range .closedOpen (importTree .getStartPosition (), endPosition ), "" );
245304 }
246305 return replacements ;
247306 }
248307
249- private static String getSimpleName (ImportTree importTree ) {
250- return importTree .getQualifiedIdentifier () instanceof JCIdent
251- ? ((JCIdent ) importTree .getQualifiedIdentifier ()).getName ().toString ()
252- : ((JCFieldAccess ) importTree .getQualifiedIdentifier ())
253- .getIdentifier ()
254- .toString ();
255- }
256-
257308 private static boolean isUnused (
258309 JCCompilationUnit unit ,
259310 Set <String > usedNames ,
260311 Multimap <String , Range <Integer >> usedInJavadoc ,
261- ImportTree importTree ,
312+ JCTree importTree ,
262313 String simpleName ) {
263- String qualifier = ((JCFieldAccess ) importTree .getQualifiedIdentifier ())
264- .getExpression ()
265- .toString ();
314+ JCFieldAccess qualifiedIdentifier = getQualifiedIdentifier (importTree );
315+ String qualifier = qualifiedIdentifier .getExpression ().toString ();
266316 if (qualifier .equals ("java.lang" )) {
267317 return true ;
268318 }
319+ if (usedNames .contains (simpleName )) {
320+ return false ;
321+ }
269322 if (unit .getPackageName () != null && unit .getPackageName ().toString ().equals (qualifier )) {
270323 return true ;
271324 }
272- if (importTree .getQualifiedIdentifier () instanceof JCFieldAccess
273- && ((JCFieldAccess ) importTree .getQualifiedIdentifier ())
274- .getIdentifier ()
275- .contentEquals ("*" )) {
325+ if (qualifiedIdentifier .getIdentifier ().contentEquals ("*" ) && !((JCImport ) importTree ).isStatic ()) {
276326 return false ;
277327 }
278328
279329 if (usedNames .contains (simpleName )) {
280330 return false ;
281331 }
282- if (usedInJavadoc .containsKey (simpleName )) {
283- return false ;
332+ return !usedInJavadoc .containsKey (simpleName );
333+ }
334+
335+ private static JCFieldAccess getQualifiedIdentifier (JCTree importTree ) {
336+ // Use reflection because the return type is JCTree in some versions and JCFieldAccess in others
337+ try {
338+ return (JCFieldAccess )
339+ JCImport .class .getMethod ("getQualifiedIdentifier" ).invoke (importTree );
340+ } catch (ReflectiveOperationException e ) {
341+ throw new LinkageError (e .getMessage (), e );
284342 }
285- return true ;
286343 }
287344
288345 /** Applies the replacements to the given source, and re-format any edited javadoc. */
0 commit comments