You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2023/08/25 16:08:10 UTC
[sis] 01/03: 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.
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 <ma...@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;