]> granicus.if.org Git - icu/blob
ddd34ea9c8a869c19c0daa075a26e44925742bcb
[icu] /
1 // © 2019 and later: Unicode, Inc. and others.
2 // License & terms of use: http://www.unicode.org/copyright.html
3 package org.unicode.icu.tool.cldrtoicu.ant;
4
5 import static com.google.common.base.Preconditions.checkArgument;
6 import static com.google.common.base.Preconditions.checkState;
7 import static com.google.common.collect.ImmutableSet.toImmutableSet;
8 import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
9 import static java.util.stream.Collectors.joining;
10 import static java.util.stream.Collectors.partitioningBy;
11
12 import java.io.BufferedReader;
13 import java.io.IOException;
14 import java.io.InputStream;
15 import java.io.InputStreamReader;
16 import java.io.UncheckedIOException;
17 import java.nio.file.Files;
18 import java.nio.file.Path;
19 import java.nio.file.Paths;
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.HashSet;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Set;
26 import java.util.TreeSet;
27 import java.util.stream.Collectors;
28 import java.util.stream.Stream;
29
30 import org.apache.tools.ant.BuildException;
31 import org.apache.tools.ant.Task;
32 import org.unicode.icu.tool.cldrtoicu.LdmlConverterConfig.IcuLocaleDir;
33
34 import com.google.common.base.CharMatcher;
35 import com.google.common.collect.ImmutableList;
36 import com.google.common.collect.ImmutableSet;
37 import com.google.common.collect.Iterables;
38 import com.google.common.io.CharStreams;
39
40 // Note: Auto-magical Ant methods are listed as "unused" by IDEs, unless the warning is suppressed.
41 public final class CleanOutputDirectoryTask extends Task {
42     private static final ImmutableSet<String> ALLOWED_DIRECTORIES =
43         Stream
44             .concat(
45                 Stream.of("misc", "translit"),
46                 Arrays.stream(IcuLocaleDir.values()).map(IcuLocaleDir::getOutputDir))
47             .sorted()
48             .collect(toImmutableSet());
49
50     private static final CharMatcher NOT_WHITESPACE = CharMatcher.whitespace().negate();
51
52     private Path root = null;
53     private boolean forceDelete = false;
54     private final List<Dir> outputDirs = new ArrayList<>();
55     private final ImmutableList<String> headerLines;
56
57     public CleanOutputDirectoryTask() {
58         // TODO: Consider passing in header lines via Ant?
59         this.headerLines = readLinesFromResource("/ldml2icu_header.txt");
60     }
61
62     public static final class Retain extends Task {
63         private Path path = null;
64
65         // Don't use "Path" for the argument type because that always makes an absolute path (e.g.
66         // relative to the working directory for the Ant task). We want relative paths.
67         @SuppressWarnings("unused")
68         public void setPath(String path) {
69             Path p = Paths.get(path).normalize();
70             checkBuild(!p.isAbsolute() && !p.startsWith(".."), "invalid path: %s", path);
71             this.path = p;
72         }
73
74         @Override
75         public void init() throws BuildException {
76             checkBuild(path != null, "missing 'path' attribute");
77         }
78     }
79
80     public static final class Dir extends Task {
81         private String name;
82         private final Set<Path> retained = new HashSet<>();
83
84         @SuppressWarnings("unused")
85         public void setName(String name) {
86             checkBuild(ALLOWED_DIRECTORIES.contains(name),
87                 "unknown directory name '%s'; allowed values: %s", name, ALLOWED_DIRECTORIES);
88             this.name = name;
89         }
90
91         @SuppressWarnings("unused")
92         public void addConfiguredRetain(Retain retain) {
93             retained.add(retain.path);
94         }
95
96         @Override
97         public void init() throws BuildException {
98             checkBuild(name != null, "missing 'name' attribute");
99         }
100     }
101
102     @SuppressWarnings("unused")
103     public void setRoot(Path root) {
104         this.root = root;
105     }
106
107     @SuppressWarnings("unused")
108     public void setForceDelete(boolean forceDelete) {
109         this.forceDelete = forceDelete;
110     }
111
112     @SuppressWarnings("unused")
113     public void addConfiguredDir(Dir dir) {
114         outputDirs.add(dir);
115     }
116
117     @Override
118     public void execute() throws BuildException {
119         checkBuild(root != null, "missing 'root' attribute");
120         checkBuild(!outputDirs.isEmpty(), "missing <dir> elements");
121
122         if (!Files.exists(root)) {
123             log("Root directory '" + root + "' does not exist (nothing to clean)");
124             return;
125         }
126         checkBuild(Files.isDirectory(root), "specified root '%s' is not a directory", root);
127
128         Set<Path> autogenFiles = new TreeSet<>();
129         Set<Path> unknownFiles = new TreeSet<>();
130         for (Dir dirInfo : outputDirs) {
131             Path dirPath = root.resolve(dirInfo.name);
132             if (!Files.exists(dirPath)) {
133                 continue;
134             }
135             checkBuild(Files.isDirectory(dirPath), "'%s' is not a directory", dirPath);
136
137             // Note: For now we just walk the immediate contents of each output directory and don't
138             // attempt to recursively process things. Only a couple of output directories have
139             // sub-directories anyway, and we never write files into them anyway.
140             try (Stream<Path> files = Files.list(dirPath)) {
141                 Map<Boolean, List<Path>> map = files
142                     .filter(p -> couldDelete(p, dirPath, dirInfo))
143                     .parallel()
144                     .collect(partitioningBy(this::wasAutoGenerated));
145                 unknownFiles.addAll(map.get(false));
146                 autogenFiles.addAll(map.get(true));
147             } catch (IOException e) {
148                 throw new BuildException("Error processing directory: " + dirPath, e);
149             }
150         }
151
152         if (!unknownFiles.isEmpty() && !forceDelete) {
153             // If there are NO safe files, then something weird is going on (perhaps a change in
154             // the header file).
155             if (autogenFiles.isEmpty()) {
156                 log("Error determining 'safe' files for deletion (no auto-generated files found).");
157                 log(unknownFiles.size() + " files would be deleted for 'clean' task");
158                 logPartioned(unknownFiles);
159                 log("Set '-DforceDelete=true' to delete all files not listed in"
160                     + " <outputDirectories>.");
161             } else {
162                 // A mix of safe and unsafe files is weird, but in this case it should be a
163                 // relatively small number of files (e.g. adding a new manually maintained file or
164                 // accidental editing of header lines).
165                 log("Unknown files exist which cannot be determined to be auto-generated");
166                 log("Files:");
167                 logPartioned(unknownFiles);
168                 log(String.format("%d unknown files or directories found", unknownFiles.size()));
169                 log("Set '-DforceDelete=true' to delete these files, or add them to"
170                     + " <outputDirectories>.");
171             }
172             throw new BuildException("Unsafe files cannot be deleted");
173         }
174         if (!unknownFiles.isEmpty()) {
175             checkState(forceDelete, "unexpected flag state (forceDelete should be true here)");
176             List<Path> filesToDelete =
177                 unknownFiles.stream()
178                     .filter(p -> !Files.isDirectory(p))
179                     .collect(Collectors.toList());
180             log(String.format("Force deleting %,d files...\n", filesToDelete.size()));
181             deleteAllFiles(filesToDelete);
182
183             List<Path> unknownDirs =
184                 unknownFiles.stream()
185                     .filter(p -> Files.isDirectory(p))
186                     .collect(Collectors.toList());
187             if (!unknownDirs.isEmpty()) {
188                 log("Add the following directories to the <outputDirectories> task:");
189                 logPartioned(unknownDirs);
190                 throw new BuildException("Unsafe directories cannot be deleted");
191             }
192         }
193         if (!autogenFiles.isEmpty()) {
194             log(String.format("Deleting %,d auto-generated files...\n", autogenFiles.size()));
195             deleteAllFiles(autogenFiles);
196         }
197     }
198
199     private void logPartioned(Iterable<Path> files) {
200         Iterables.partition(files, 5)
201             .forEach(f -> log(
202                 f.stream().map(p -> root.relativize(p).toString()).collect(joining(", "))));
203     }
204
205     private boolean couldDelete(Path path, Path dir, Dir dirInfo) {
206         return !dirInfo.retained.contains(dir.relativize(path));
207     }
208
209     private boolean wasAutoGenerated(Path path) {
210         if (!Files.isRegularFile(path, NOFOLLOW_LINKS)) {
211             // Directories, symbolic links, devices etc.
212             return false;
213         }
214         try (BufferedReader r = Files.newBufferedReader(path)) {
215             // A byte-order-mark (BOM) is added to ICU data files, but not JSON deps files, so just
216             // treat it as optional everywhere (it's not the important thing we check here).
217             r.mark(1);
218             int maybeByteOrderMark = r.read();
219             if (maybeByteOrderMark != '\uFEFF') {
220                 // Also reset if the file was empty, but that should be harmless.
221                 r.reset();
222             }
223             for (String headerLine : headerLines) {
224                 String line = r.readLine();
225                 if (line == null) {
226                     return false;
227                 }
228                 int headerStart = skipComment(line);
229                 if (headerStart < 0
230                     || !line.regionMatches(headerStart, headerLine, 0, headerLine.length())) {
231                     return false;
232                 }
233             }
234             return true;
235         } catch (IOException e) {
236             throw new UncheckedIOException(e);
237         }
238     }
239
240     private static int skipComment(String line) {
241         if (line.startsWith("#")) {
242             return toCommentStart(line, 1);
243         } else if (line.startsWith("//")) {
244             return toCommentStart(line, 2);
245         }
246         return -1;
247     }
248
249     // Not just "index-of" since a comment start followed by only whitespace is NOT a failure to
250     // find a comment (since the header might have an empty line in it, which should be okay).
251     private static int toCommentStart(String line, int offset) {
252         int index = NOT_WHITESPACE.indexIn(line, offset);
253         return index >= 0 ? index : line.length();
254     }
255
256     private static void deleteAllFiles(Iterable<Path> files) {
257         for (Path p : files) {
258             try {
259                 // This is a code error, since only files should be passed here.
260                 checkArgument(!Files.isDirectory(p), "Cannot delete directories: %s", p);
261                 Files.deleteIfExists(p);
262             } catch (IOException e) {
263                 throw new BuildException("Error deleting file: " + p, e);
264             }
265         }
266     }
267
268     private static void checkBuild(boolean condition, String message, Object... args) {
269         if (!condition) {
270             throw new BuildException(String.format(message, args));
271         }
272     }
273
274     private static ImmutableList<String> readLinesFromResource(String name) {
275         try (InputStream in = CleanOutputDirectoryTask.class.getResourceAsStream(name)) {
276             return ImmutableList.copyOf(CharStreams.readLines(new InputStreamReader(in)));
277         } catch (IOException e) {
278             throw new RuntimeException("cannot read resource: " + name, e);
279         }
280     }
281 }