View Javadoc
1   /*
2    * SPDX-FileCopyrightText: Copyright (c) 2011-2026 Yegor Bugayenko
3    * SPDX-License-Identifier: MIT
4    */
5   package com.qulice.errorprone;
6   
7   import com.qulice.spi.Environment;
8   import com.qulice.spi.Relative;
9   import java.io.File;
10  import java.util.ArrayList;
11  import java.util.LinkedHashMap;
12  import java.util.List;
13  import java.util.Map;
14  import java.util.regex.Pattern;
15  import org.apache.commons.io.FilenameUtils;
16  
17  /**
18   * Java sources split the way Maven compiles them: one batch per source
19   * root, never two roots in one batch.
20   *
21   * <p>A single {@code javac} pass over two roots would manufacture
22   * diagnostics no project could ever silence, because Maven lets every
23   * source root hold a twin of the same file. The clearest case is
24   * {@code package-info.java}, which Qulice's own {@code JavadocPackage}
25   * check demands in every package: with one pass, a package present in two
26   * roots earns {@code a package-info.java file has already been seen} and,
27   * once the file carries an annotation, {@code has already been
28   * annotated}.</p>
29   *
30   * <p>The roots a file may sit under are the ones the project declares,
31   * which is what {@link Environment#testdirs()} reports. Matching the
32   * {@code /src/test/} path fragment instead would miss every further root a
33   * project registers through
34   * {@code build-helper-maven-plugin:add-test-source} — {@code src/mock/java}
35   * in the jcabi parent POM (see
36   * <a href="https://github.com/yegor256/qulice/issues/1742">#1742</a>).
37   * Folding all of those roots into one {@code test} batch does not help
38   * either: {@code src/test/java} and {@code src/mock/java} are as free to
39   * hold twins as {@code src/main/java} and {@code src/test/java} are, so
40   * each root earns a batch of its own (see
41   * <a href="https://github.com/yegor256/qulice/issues/1745">#1745</a>). A
42   * file under none of them belongs to the main batch. Cross-batch
43   * references still resolve, since the project's own compiled classes are
44   * on the {@code -classpath} of every pass.</p>
45   *
46   * @since 1.0
47   */
48  public final class Batches {
49  
50      /**
51       * Name of the batch of the sources that sit under no declared test
52       * source root. Every other batch this class makes holds test sources,
53       * which is how {@link Release} tells the two apart.
54       */
55      static final String MAIN = "main";
56  
57      /**
58       * Runs of characters that have no place in a file name, in the path of
59       * a source root that {@link #label(File, int)} turns into a batch name.
60       */
61      private static final Pattern SEPARATOR = Pattern.compile("[^A-Za-z0-9]+");
62  
63      /**
64       * The dashes {@link #SEPARATOR} leaves at either end of a batch name.
65       */
66      private static final Pattern EDGES = Pattern.compile("^-+|-+$");
67  
68      /**
69       * Environment that knows the source roots.
70       */
71      private final Environment env;
72  
73      /**
74       * Java source files to split.
75       */
76      private final List<File> sources;
77  
78      /**
79       * Constructor.
80       *
81       * @param env Environment that knows the source roots
82       * @param sources Java source files to split
83       */
84      public Batches(final Environment env, final List<File> sources) {
85          this.env = env;
86          this.sources = sources;
87      }
88  
89      /**
90       * Split the sources, one batch per source root.
91       *
92       * @return Batches by name, in compilation order, none of them empty
93       */
94      public Map<String, List<File>> split() {
95          final Map<String, String> roots = new LinkedHashMap<>(0);
96          int index = 0;
97          for (final File dir : this.env.testdirs()) {
98              index += 1;
99              roots.putIfAbsent(
100                 Batches.slashed(dir).concat("/"), this.label(dir, index)
101             );
102         }
103         final Map<String, List<File>> batches = new LinkedHashMap<>(0);
104         batches.put(Batches.MAIN, new ArrayList<>(0));
105         for (final String name : roots.values()) {
106             batches.put(name, new ArrayList<>(0));
107         }
108         for (final File source : this.sources) {
109             batches.get(
110                 Batches.owner(Batches.slashed(source), roots)
111             ).add(source);
112         }
113         batches.values().removeIf(List::isEmpty);
114         return batches;
115     }
116 
117     private String label(final File root, final int index) {
118         return String.format(
119             "test-%d-%s",
120             index,
121             Batches.EDGES.matcher(
122                 Batches.SEPARATOR.matcher(
123                     new Relative(this.env.basedir(), root).path()
124                 ).replaceAll("-")
125             ).replaceAll("")
126         );
127     }
128 
129     private static String owner(final String path,
130         final Map<String, String> roots) {
131         String name = Batches.MAIN;
132         int longest = 0;
133         for (final Map.Entry<String, String> root : roots.entrySet()) {
134             final String dir = root.getKey();
135             if (dir.length() > longest && path.startsWith(dir)) {
136                 longest = dir.length();
137                 name = root.getValue();
138             }
139         }
140         return name;
141     }
142 
143     private static String slashed(final File file) {
144         final String absolute = file.getAbsolutePath();
145         String path = FilenameUtils.normalize(absolute, true);
146         if (path == null) {
147             path = absolute.replace(File.separatorChar, '/');
148         }
149         return path;
150     }
151 }