This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit d51e2822273508e42163de107f866fd14560d4b1 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Fri Aug 25 16:24:36 2023 +0200 Add the tool used for reordering the import statements (for project maintainers only). This tool is not executed automatically, it must be invoked on the command-line when desired. --- .../sis/buildtools/coding/ReorganizeImports.java | 522 +++++++++++++++++++++ .../apache/sis/buildtools/coding/package-info.java | 27 ++ 2 files changed, 549 insertions(+) diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/coding/ReorganizeImports.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/coding/ReorganizeImports.java new file mode 100644 index 0000000000..1ec2140229 --- /dev/null +++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/coding/ReorganizeImports.java @@ -0,0 +1,522 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.sis.buildtools.coding; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + + +/** + * Reorganizes the import statements with a different sections for imports that are specific to a branch. + * The goal is to separate in different groups the imports that are different between the branches. + * Each group is identified by a label such as "// Specific to geoapi-3.1 and geoapi-4.0 branches:". + * This separation makes easier to resolve conflicts during branch merges. + * + * <p>This program also opportunistically brings together the imports of the same packages. + * Except for the above, import order is not modified: no alphabetical order is enforced. + * Other opportunistic cleanups are the removal of zero-width spaces and trailing spaces.</p> + * + * <h2>How to use</h2> + * A directory must contain a checkout of the three Apache SIS branches in directories of + * the same name as the branches: {@code main}, {@code geoapi-3.1} and {@code geoapi-4.0}. + * The commit on all branches shall be right after a fresh merge of development branches. + * Run the following command in that directory where "." is the current directory + * (can be replaced by a path if desired): + * + * {@snippet lang="shell" : + * java --class-path main/buildSrc/build/classes/java/main org.apache.sis.buildtools.coding.ReorganizeImports . + * } + * + * Above command will modify all above-cited branches. + * First test and commit {@code geoapi-4.0}: + * + * {@snippet lang="shell" : + * cd geoapi-4.0 + * git add --update + * git diff --staged + * gradle test + * git diff + * git add --update + * git commit + * } + * + * Then temporarily stash the changes in {@code geoapi-3.1}, merge, pop the stashed changes, test and commit: + * + * {@snippet lang="shell" : + * cd ../geoapi-3.1 + * git add --update + * git diff --staged + * git stash --message "Import reordering" + * git merge geoapi-4.0 -s ours --no-commit + * git stash pop + * git add --update + * gradle test + * git diff + * git add --update + * git commit + * } + * + * Finally apply the same pattern on the {@code main} branch. + * + * @author Martin Desruisseaux (Geomatys) + */ +@SuppressWarnings("UseOfSystemOutOrSystemErr") +public final class ReorganizeImports extends SimpleFileVisitor<Path> { + /** + * Reorganize imports in the three Apache SIS development branches of a root directory. + * + * @param args the root directory of all branches. + * @throws IOException if an error occurred while reading or writing a file. + */ + public static void main(final String[] args) throws IOException { + if (args.length != 1) { + System.err.println("Expected: root directory of main, geoapi-3.1 and geoapi-4.0 branches."); + return; + } + final Path root = Path.of(args[0]); + final var geoapi4 = new ReorganizeImports(root.resolve("geoapi-4.0"), 0); + final var geoapi3 = new ReorganizeImports(root.resolve("geoapi-3.1"), 1); + final var main = new ReorganizeImports(root.resolve("main"), 2); + final String[] branchNames = compareUsages(geoapi4, geoapi3, main); + geoapi4.rewrite(branchNames); + geoapi3.rewrite(branchNames); + main .rewrite(branchNames); + } + + /** + * Directories to exclude. + */ + private static final Set<String> EXCLUDES = Set.of( + "snapshot", // geoapi/snapshot + "org.apache.sis.test.uncommitted"); + + /** + * Java keyword to search for, in order and with a trailing space. + */ + private static final String PACKAGE = "package ", IMPORT = "import ", STATIC = "static "; + + /** + * Whether to sort classes inside a group of classes (a package). + * Packages are not sorted because the order used in source code + * is usually intentional (Java classes first, then GeoAPI, <i>etc.</i>). + */ + private static final boolean SORT = false; + + /** + * Root directory of the project for which to reorganize imports. + */ + private final Path root; + + /** + * A mask with a single bit set for identifying the branch of the sources. + */ + private final int bitmask; + + /** + * List of import statements. Keys are relative paths to the Java source file, + * and values are the import statements for that class. + */ + private final Map<Path,Source> sources; + + /** + * Creates a new import reorganizer. + * + * @param root root directory of the project for which to reorganize imports. + * @param ordinal a sequential number identifying the branch of the source. + * @throws IOException if an error occurred while reading a file. + */ + @SuppressWarnings("ThisEscapedInObjectConstruction") + private ReorganizeImports(final Path root, final int ordinal) throws IOException { + this.root = root; + bitmask = 1 << ordinal; + sources = new LinkedHashMap<>(); + Files.walkFileTree(root, this); + } + + /** + * Checks whether the specified directory should be visited. + * + * @param directory the directory to potentially visit. + * @param attributes ignored. + * @return whether to walk in the directory or skip it. + */ + @Override + public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAttributes attributes) { + if (EXCLUDES.contains(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + /** + * Invoked for each file to filter. + * If the file does not have one of the hard-coded extensions, then this method does nothing. + * + * @param file the file in which to reorganize imports. + * @param attributes ignored. + * @return whether to continue filtering. + * @throws IOException if an error occurred while reading the file. + */ + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) throws IOException { + if (file.getFileName().toString().endsWith(".java")) { + final var source = new Source(Files.readAllLines(file), bitmask); + if (!source.isEmpty()) { + if (sources.put(root.relativize(file), source) != null) { + throw new IOException("Duplicated file: " + file); + } + } + } + return FileVisitResult.CONTINUE; + } + + /** + * The source code of a Java source file, with import statements handled separately. + */ + private static final class Source { + /** + * Lines before (header) and after (body) the import statements. + */ + private String[] header, body; + + /** + * The imported package names, together with a bitmask telling in which branches they are found. + */ + private final Map<String,Integer> imports; + + /** + * If some import statements were followed by a comment, the comment. + */ + private final Map<String,String> comments; + + /** + * Column where to write the comments. + * This computed from the length of the largest import statement having a comment. + */ + private int commentPosition; + + /** + * Parses the given lines of code. + * + * @param lines lines of code of a Java source file. + * @param bitmask a mask with a bit set to identify the branch of the source. + */ + Source(final List<String> lines, final Integer bitmask) { + imports = new LinkedHashMap<>(); + comments = new HashMap<>(); + final var elements = new ArrayList<String>(); + final int size = lines.size(); + for (int i=0; i<size; i++) { + String line = lines.get(i).trim(); + if (header == null) { + if (line.startsWith(PACKAGE)) { + header = lines.subList(0, i+1).toArray(String[]::new); + } + } else if (line.startsWith(IMPORT)) { + int s = line.indexOf(';'); + final String element = line.substring(IMPORT.length(), s).trim(); + elements.add(element); + if (++s < line.length()) { + final String comment = line.substring(s).trim(); + if (!comment.isEmpty()) { + comments.put(element, comment); + commentPosition = Math.max(commentPosition, line.indexOf(comment)); + } + } + } else if (!line.isEmpty() && !line.startsWith("//")) { + body = lines.subList(i, size).toArray(String[]::new); + break; + } + } + for (final String element : sort(elements)) { + imports.put(element, bitmask); + } + } + + /** + * Sorts import statements. This method does not use alphabetic order. + * We rather preserve the existing order in source code. + * This method only puts together the imports having the same package name. + * + * <h4>Performance note</h4> + * This implementation is not efficient. However this class is not executed often, + * so we do not bother to optimize it. + */ + private static String[] sort(final List<String> elements) { + final String[] ordered = new String[elements.size()]; + int count = 0; + for (;;) { + /* + * Take the next group of imports (usually a package name). + * Before to use it, check if a parent is defined afterward. + */ + int index = -1; + String group = null; + for (int i=0; i < elements.size(); i++) { + final String candidate = getGroupName(elements.get(i)); + if (group != null) { + if ( candidate.startsWith(group)) continue; // Include the case when same group. + if (!group.startsWith(candidate)) continue; // Include the case when `group` is too short. + if (group.charAt(candidate.length()) != '.') continue; + } + group = candidate; + index = i; + } + /* + * Move together all imports of the same group (package). + * Classes inside the same group are sorted in alphabetical order. + * However the order of group is kept unchanged, because the order + * is usually "Java first, then Jakarta, then GeoAPI, then SIS". + */ + if (group == null) break; + final int start = count; + ordered[count++] = elements.remove(index); + final Iterator<String> it = elements.iterator(); + while (it.hasNext()) { + final String element = it.next(); + if (group.equals(getGroupName(element))) { + ordered[count++] = element; + it.remove(); + } + } + if (SORT) { + Arrays.sort(ordered, start, count); + } + } + if (count != ordered.length) { + throw new AssertionError(count); + } + return ordered; + } + + /** + * Returns the name of a group of import statements. + * This is usually the package name. + * + * @param element the name of the imported element(s). + * @return the name of the group (usually package name). + */ + private static String getGroupName(final String element) { + String prefix = element.substring(0, element.lastIndexOf('.')).trim(); + if (!prefix.startsWith(STATIC)) { + final int p = prefix.lastIndexOf('.'); + if (p >= 0 && Character.isUpperCase(prefix.codePointAt(p+1))) { + // Import of an inner class. Go up to the package name. + prefix = prefix.substring(0, p); + } + } + // Consider "javax" and synonymous of "java" for sorting purpose. + prefix = prefix.replace("javax", "java"); + prefix = prefix.replace("java.nio", "java.io"); + return prefix; + } + + /** + * Returns {@code true} if this object contains no information. + * It happens with {@code module-info.java} files because they + * contain to {@code "package"} keyword. + * + * @return whether this object would write an empty file. + */ + final boolean isEmpty() { + return header == null; + } + + /** + * Writes the source code of this source file into the given list. + * The {@link #updateUsageFlags(Source)} should have been invoked + * before this method in order to categorize the import statements. + * This method shall be invoked only once per {@code Source}. + * + * @param dest where to write the lines of source code. + * @param branchNames names of each branch. + */ + final void writeTo(final List<String> dest, final String[] branchNames) { + /* + * Copyright header and "package" statement. + */ + dest.addAll(Arrays.asList(header)); + /* + * Write the import statements, starting with the imports that apply to all branches. + * Then the imports for specific branches are written in separated block below a header + * saying on which branches the imports apply. + */ + boolean needSeparator = true; // Whether to write a line separator before next line. + boolean needHeader = false; // Whether to write a comment like "// Specific to main branch:". + boolean staticImports = false; // Whether we are writing static imports or ordinary imports. + boolean isStaticValid = false; // Whether the `staticImports` flag is valid. + final var buffer = new StringBuilder(80); + int bitmask = (1 << branchNames.length) - 1; + while (bitmask > 0) { + final Iterator<Map.Entry<String,Integer>> it = imports.entrySet().iterator(); + while (it.hasNext()) { + final Map.Entry<String,Integer> entry = it.next(); + if (entry.getValue() == bitmask) { + /* + * If we are starting a new group of imports that are specific to some branches, + * write a comment with the names of all branches that are using those imports. + */ + if (needHeader) { + dest.add(""); + final var sb = new StringBuilder("// Specific to the "); + int namesToAdd = bitmask; + do { + final int i = (Integer.SIZE - 1) - Integer.numberOfLeadingZeros(namesToAdd); + sb.append(branchNames[i]); + namesToAdd &= ~(1 << i); + final int remaining = Integer.bitCount(namesToAdd); + if (remaining > 0) { + sb.append(remaining > 1 ? ", " : " and "); + } + } while (namesToAdd != 0); + sb.append(" branch"); + if (Integer.bitCount(bitmask) > 1) sb.append("es"); // Make plural. + dest.add(sb.append(':').toString()); + needSeparator = false; + needHeader = false; + isStaticValid = false; + } + /* + * Write a empty line separator if we are moving from a group of ordinary + * imports to a group of static imports, then add the import statement. + */ + final String element = entry.getKey(); + needSeparator |= (staticImports != (staticImports = element.startsWith(STATIC)) && isStaticValid); + if (needSeparator) { + needSeparator = false; + dest.add(""); + } + isStaticValid = true; + buffer.append(IMPORT).append(element).append(';'); + final String comment = comments.remove(element); + if (comment != null) { + for (int i = commentPosition - buffer.length(); --i >= 0;) { + buffer.append(' '); + } + buffer.append(comment); + } + dest.add(buffer.toString()); + buffer.setLength(0); + it.remove(); + } + } + needHeader = true; + bitmask--; + } + if (!imports.isEmpty()) { + throw new RuntimeException("Non-categorized import statements."); + } + /* + * Actual source code after the import statements. + */ + if (body != null) { + dest.add(""); + dest.add(""); + dest.addAll(Arrays.asList(body)); + } + } + + /** + * Takes notes of all import statements that are also used in the given source. + * This is used for comparing the source of the same class on two different branches. + * + * @param other the same source file on another branch. + */ + final void updateUsageFlags(final Source other) { + other.imports.forEach((element, bitmask) -> { + imports.computeIfPresent(element, (key,value) -> value | bitmask); + }); + } + } + + /** + * Compares the import statements between all branches. + * A flag is set of each statement for remembering which branches use it. + * + * <h4>Performance note</h4> + * Current implementation is not efficient because for each equal key, + * the same value is computed in all branches. However this class is not + * executed often, so we do not bother to optimize it. + * + * @param organizers source files of all branches. + * @return names of all branches. + */ + private static String[] compareUsages(final ReorganizeImports... organizers) { + final String[] branchNames = new String[organizers.length]; + for (final ReorganizeImports organizer : organizers) { + for (final ReorganizeImports other : organizers) { + if (other != organizer) { + final Map<Path,Source> sources = organizer.sources; + other.sources.forEach((path, osrc) -> { + final Source source = sources.get(path); + if (source != null) source.updateUsageFlags(osrc); + }); + } + } + branchNames[Integer.numberOfTrailingZeros(organizer.bitmask)] = organizer.root.getFileName().toString(); + } + return branchNames; + } + + /** + * Rewrites all source files of this branch with import statements reorganized. + * The source files of other branches are used for categorizing the import statements. + * This method can be invoked only once. + * + * @param branchNames name of all branches. + * @throws IOException if an error occurred while writing a file. + */ + private void rewrite(final String[] branchNames) throws IOException { + final var lines = new ArrayList<String>(); + for (final Map.Entry<Path,Source> entry : sources.entrySet()) { + entry.getValue().writeTo(lines, branchNames); + lines.replaceAll(ReorganizeImports::removeExtraneousSpaces); + Files.write(root.resolve(entry.getKey()), lines, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + lines.clear(); + } + } + + /** + * Opportunistic cleanup: removes all zero-width spaces is source code. + * Those spaces are inserted in HTML pages for allowing the browsers to split long statements on + * two lines, for example after each dot in {@code org.apache.sis.referencing.operation.transform}. + * Those characters are accidentally introduced in Java code when doing a copy-and-paste from Javadoc. + * This method removes them. + * + * @param line the line to filter. + * @return the filtered line. + */ + private static String removeExtraneousSpaces(String line) { + line = line.replace("\u200B", ""); + int i = line.length(); + while (i > 0 && Character.isWhitespace(line.codePointBefore(i))) i--; + return line.substring(0, i); + } +} diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/coding/package-info.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/coding/package-info.java new file mode 100644 index 0000000000..c43d55ca1d --- /dev/null +++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/coding/package-info.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +/** + * Tools that generate or rewrite some Java codes. + * Those tools are not executed automatically; + * they must be invoked explicitly on the command line. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.4 + * @since 1.4 + */ +package org.apache.sis.buildtools.coding;