001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyrighted [year] [name of copyright owner]".
013 *
014 * Copyright 2011 ForgeRock AS
015 * Portions Copyright 2022 Wren Security.
016 */
017package org.forgerock.i18n.maven;
018
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.FileOutputStream;
022import java.io.IOException;
023import java.io.InputStreamReader;
024import java.io.LineNumberReader;
025import java.io.OutputStreamWriter;
026import java.util.ArrayList;
027import java.util.Iterator;
028import java.util.LinkedList;
029import java.util.List;
030import java.util.Map;
031import java.util.Properties;
032import java.util.concurrent.ConcurrentHashMap;
033import java.util.concurrent.ExecutorService;
034import java.util.concurrent.Executors;
035import java.util.concurrent.TimeUnit;
036import java.util.concurrent.atomic.AtomicInteger;
037
038import org.apache.maven.plugin.AbstractMojo;
039import org.apache.maven.plugin.MojoExecutionException;
040import org.apache.maven.plugins.annotations.Mojo;
041import org.apache.maven.plugins.annotations.Parameter;
042
043/**
044 * Goal which cleans unused messages files from a property file.
045 */
046@Mojo(name="clean-messages", threadSafe=true)
047@SuppressWarnings("resource")
048public final class CleanMessagesMojo extends AbstractMojo {
049
050    /**
051     * A task which searches a single source file for any messages.
052     */
053    private final class SourceFileTask implements Runnable {
054
055        private final File sourceFile;
056
057        private SourceFileTask(final File sourceFile) {
058            this.sourceFile = sourceFile;
059        }
060
061        /**
062         * {@inheritDoc}
063         */
064        public void run() {
065            // Cache the keys that we want to check so that we avoid excessive
066            // contention on the CHM.
067            final List<MessagePropertyKey> keys = new LinkedList<MessagePropertyKey>(
068                    unreferencedProperties.keySet());
069
070            try {
071                final FileInputStream s = new FileInputStream(sourceFile);
072                try {
073                    final LineNumberReader reader = new LineNumberReader(
074                            new InputStreamReader(s, encoding));
075
076                    String line;
077                    while ((line = reader.readLine()) != null) {
078                        final Iterator<MessagePropertyKey> i = keys.iterator();
079                        while (i.hasNext()) {
080                            final MessagePropertyKey key = i.next();
081                            if (key.isPresent(line)) {
082                                i.remove();
083                                unreferencedProperties.remove(key);
084                                referencedProperties.put(key, ""); // Dummy
085                                                                   // value.
086                            }
087                        }
088                    }
089                } finally {
090                    try {
091                        s.close();
092                    } catch (final Exception ignored) {
093                        // Ignore.
094                    }
095                }
096
097                fileCount.incrementAndGet();
098            } catch (final IOException e) {
099                getLog().error(
100                        "An error occurred while reading source file "
101                                + sourceFile.getName(), e);
102            }
103        }
104
105    }
106
107    /**
108     * The target directory in which the source files should be generated.
109     */
110    @Parameter(defaultValue="${project.build.sourceDirectory}", required=true)
111    private File sourceDirectory;
112
113    /**
114     * The message message file to be cleaned.
115     */
116    @Parameter(required=true)
117    private File messageFile;
118
119    /**
120     * The encoding argument used by Java source files.
121     */
122    @Parameter(defaultValue="${project.build.sourceEncoding}", required=true)
123    private String encoding;
124
125    private final Map<MessagePropertyKey, String> unreferencedProperties =
126            new ConcurrentHashMap<MessagePropertyKey, String>();
127    private final Map<MessagePropertyKey, String> referencedProperties =
128            new ConcurrentHashMap<MessagePropertyKey, String>();
129    private final AtomicInteger fileCount = new AtomicInteger();
130
131    /**
132     * {@inheritDoc}
133     */
134    // @Checkstyle:ignore
135    public void execute() throws MojoExecutionException {
136        if (!sourceDirectory.exists()) {
137            throw new MojoExecutionException("Source directory "
138                    + sourceDirectory.getPath() + " does not exist");
139        } else if (!sourceDirectory.isDirectory()) {
140            throw new MojoExecutionException("Source directory "
141                    + sourceDirectory.getPath() + " is not a directory");
142        }
143
144        if (!messageFile.exists()) {
145            throw new MojoExecutionException("Message file "
146                    + messageFile.getPath() + " does not exist");
147        } else if (!messageFile.isFile()) {
148            throw new MojoExecutionException("Message file "
149                    + messageFile.getPath() + " is not a file");
150        }
151
152        final Properties properties = new Properties();
153
154        try {
155            final FileInputStream propertiesFile = new FileInputStream(
156                    messageFile);
157            try {
158                properties.load(propertiesFile);
159            } finally {
160                try {
161                    propertiesFile.close();
162                } catch (final Exception ignored) {
163                    // Ignore.
164                }
165            }
166        } catch (final IOException e) {
167            throw new MojoExecutionException(
168                    "An IO error occurred while reading the message property file: "
169                            + e);
170        }
171
172        // Initially all properties are deemed to have no references.
173        for (final Map.Entry<Object, Object> property : properties.entrySet()) {
174            final String propKey = property.getKey().toString();
175            final MessagePropertyKey key = MessagePropertyKey.valueOf(propKey);
176            unreferencedProperties.put(key, property.getValue().toString());
177        }
178
179        final int messageCount = unreferencedProperties.size();
180
181        // Process source files in parallel.
182        final ExecutorService executor = Executors.newFixedThreadPool(Runtime
183                .getRuntime().availableProcessors() * 2);
184
185        // Recursively add source files to task queue.
186        processSourceDirectory(executor, sourceDirectory);
187
188        executor.shutdown();
189        try {
190            executor.awaitTermination(1, TimeUnit.DAYS);
191        } catch (final InterruptedException e) {
192            Thread.currentThread().interrupt();
193            throw new MojoExecutionException(
194                    "Interrupted while processing source files");
195        }
196
197        if ((unreferencedProperties.size() + referencedProperties.size()) != messageCount) {
198            throw new IllegalStateException("Message table sizes are invalid");
199        }
200
201        getLog().info("Processed " + fileCount.get() + " source files");
202        getLog().info(
203                "Found " + unreferencedProperties.size() + " / " + messageCount
204                        + " unreferenced properties");
205
206        // All source files processed and each message is known to be either
207        // referenced or not. Re-write the property file including only the
208        // referenced properties.
209        int cleanedMessageCount = 0;
210        int savedMessageCount = 0;
211
212        final List<String> lines = new ArrayList<String>(10000);
213        try {
214            final FileInputStream propertiesFile = new FileInputStream(
215                    messageFile);
216            try {
217                final LineNumberReader reader = new LineNumberReader(
218                        new InputStreamReader(propertiesFile, "ISO-8859-1"));
219
220                String line;
221                boolean inValue = false;
222                boolean lineNeedsRemoving = false;
223                boolean foundErrors = false;
224                while ((line = reader.readLine()) != null) {
225                    if (!inValue) {
226                        // This could be a comment or the start of a new
227                        // property.
228                        final String trimmedLine = line.trim();
229                        if (trimmedLine.length() == 0) {
230                            lines.add(line);
231                        } else if (trimmedLine.startsWith("#")) {
232                            lines.add(line);
233                        } else {
234                            final String key;
235                            final int separator = trimmedLine.indexOf('=');
236                            if (separator < 0) {
237                                key = trimmedLine;
238                            } else {
239                                key = trimmedLine.substring(0, separator)
240                                        .trim();
241                            }
242
243                            MessagePropertyKey mpk;
244                            try {
245                                mpk = MessagePropertyKey.valueOf(key);
246                            } catch (IllegalArgumentException e) {
247                                getLog().error(
248                                        "Unable to decode line "
249                                                + reader.getLineNumber() + ": "
250                                                + line, e);
251                                lines.add(line);
252                                lineNeedsRemoving = false;
253                                inValue = isContinuedOnNextLine(line);
254                                foundErrors = true;
255                                continue;
256                            }
257
258                            if (referencedProperties.containsKey(mpk)) {
259                                savedMessageCount++;
260                                lines.add(line);
261                                lineNeedsRemoving = false;
262                            } else {
263                                if (!unreferencedProperties.containsKey(mpk)) {
264                                    throw new IllegalStateException(
265                                            "Unregistered message key");
266                                }
267                                cleanedMessageCount++;
268                                lineNeedsRemoving = true;
269                            }
270                            inValue = isContinuedOnNextLine(line);
271                        }
272                    } else {
273                        // This is a continuation line.
274                        if (!lineNeedsRemoving) {
275                            lines.add(line);
276                        }
277
278                        inValue = isContinuedOnNextLine(line);
279                    }
280                }
281
282                if (foundErrors) {
283                    throw new MojoExecutionException(
284                            "Aborting because the message file could not be parsed");
285                }
286            } finally {
287                try {
288                    propertiesFile.close();
289                } catch (final Exception ignored) {
290                    // Ignore.
291                }
292            }
293        } catch (final IOException e) {
294            throw new MojoExecutionException(
295                    "An IO error occurred while reading the message property file: "
296                            + e);
297        }
298
299        // Now write out the cleaned message file.
300        if (cleanedMessageCount == 0) {
301            // Nothing to do.
302            getLog().info(
303                    "Message file " + messageFile.getName()
304                            + " unchanged: no messages were cleaned");
305        } else {
306            try {
307                final FileOutputStream propertiesFile = new FileOutputStream(
308                        messageFile);
309                try {
310                    final OutputStreamWriter writer = new OutputStreamWriter(
311                            propertiesFile, "ISO-8859-1");
312                    final String eol = System.getProperty("line.separator");
313                    for (final String line : lines) {
314                        writer.write(line);
315                        writer.write(eol);
316                    }
317
318                    writer.close();
319                } finally {
320                    try {
321                        propertiesFile.close();
322                    } catch (final Exception ignored) {
323                        // Ignore.
324                    }
325                }
326            } catch (final IOException e) {
327                throw new MojoExecutionException(
328                        "An IO error occurred while writing the message property file: "
329                                + e);
330            }
331
332            getLog().info(
333                    "Message file " + messageFile.getName() + " cleaned: "
334                            + cleanedMessageCount + " messages removed and "
335                            + savedMessageCount + " kept");
336        }
337    }
338
339    // Check to see if this line ends with a continuation character (odd
340    // number of consecutive back-slash).
341    boolean isContinuedOnNextLine(final String line) {
342        int bsCount = 0;
343        for (int i = line.length() - 1; i >= 0 && line.charAt(i) == '\\'; i--) {
344            bsCount++;
345        }
346
347        return ((bsCount % 2) == 1);
348    }
349
350    // Recursively create tasks for each Java source file in the provided source
351    // directory and sub-directories.
352    private void processSourceDirectory(final ExecutorService executor,
353            final File s) {
354        for (final File f : s.listFiles()) {
355            if (f.isDirectory()) {
356                processSourceDirectory(executor, f);
357            } else if (f.isFile()) {
358                if (f.getName().endsWith(".java")) {
359                    // Add a task for processing this source file.
360                    executor.execute(new SourceFileTask(f));
361                }
362            }
363        }
364    }
365}