/* * GeoTools - The Open Source Java GIS Toolkit * http://geotools.org * * (C) 2001-2008, Open Source Geospatial Foundation (OSGeo) * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; * version 2.1 of the License. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. */ package org.geotools.resources; import java.io.*; import java.util.*; import java.text.MessageFormat; import java.lang.reflect.Field; /** * Resource compiler. This class is run from the command line at compile time only. * {@code IndexedResourceCompiler} scans for {@code .properties} files and copies their content * to {@code .utf} files using UTF8 encoding. It also checks for key validity and checks values * for {@link MessageFormat} compatibility. Finally, it creates a {@code FooKeys.java} source * file declaring resource keys as integer constants. * <p> * This class <strong>must</strong> be run from the maven root of Geotools project. * <p> * {@code IndexedResourceCompiler} and all {@code FooKeys} classes don't need to be included in the * final JAR file. They are used at compile time only and no other classes should keep reference to * them. * * @since 2.4 * * @source $URL$ * @version $Id$ * @author Martin Desruisseaux (IRD) */ public final class IndexedResourceCompiler implements Comparator<Object> { /** * The base directory for {@code "java"} {@code "resources"} sub-directories. * The directory structure must be consistent with Maven conventions. * * @see #sourceDirectory */ private static final File SOURCE_DIRECTORY = new File("./src/main"); /** * The resources to process. */ @SuppressWarnings("unchecked") private static final Class<? extends IndexedResourceBundle>[] RESOURCES_TO_PROCESS = new Class[] { org.geotools.resources.i18n.Descriptions.class, org.geotools.resources.i18n.Vocabulary .class, org.geotools.resources.i18n.Loggings .class, org.geotools.resources.i18n.Errors .class }; /** * Extension for properties source files. * Must be in the {@code ${sourceDirectory}/java} directory. */ private static final String PROPERTIES_EXT = ".properties"; /** * Extension for resource target files. * Will be be in the {@code ${sourceDirectory}/resources} directory. */ private static final String RESOURCES_EXT = ".utf"; /** * Prefix for argument count in resource key names. For example, a resource * expecting one argument may have a key name like "HELLO_$1". */ private static final String ARGUMENT_COUNT_PREFIX = "_$"; /** * The maximal length of comment lines. */ private static final int COMMENT_LENGTH = 92; /** * The base directory for {@code "java"} {@code "resources"} sub-directories. * The directory structure must be consistent with Maven conventions. */ private final File sourceDirectory; /** * Integer IDs allocated to resource keys. This map will be shared for all languages * of a given resource bundle. */ private final Map<Integer,String> allocatedIDs = new HashMap<Integer,String>(); /** * Resource keys and their localized values. This map will be cleared for each language * in a resource bundle. */ private final Map<Object,Object> resources = new HashMap<Object,Object>(); /** * The output stream for printing message. */ private final PrintWriter out; /** * Constructs a new {@code IndexedResourceCompiler}. This method will immediately look for * a {@code FooKeys.class} file. If one is found, integer keys are loaded in order to reuse * the same values. * * @param sourceDirectory The base directory for {@code "java"} {@code "resources"} * sub-directories. The directory structure must be consistent with Maven conventions. * @param bundleClass The resource bundle base class * (e.g. <code>{@linkplain org.geotools.resources.i18n.Vocabulary}.class}</code>). * @param renumber {@code true} for renumbering all key values. * @param out The output stream for printing message. * @throws IOException if an input/output operation failed. */ private IndexedResourceCompiler(final File sourceDirectory, final Class<? extends IndexedResourceBundle> bundleClass, final boolean renumber, final PrintWriter out) throws IOException { this.sourceDirectory = sourceDirectory; this.out = out; if (!renumber) try { final String classname = toKeyClass(bundleClass.getName()); final Field[] fields = Class.forName(classname).getFields(); out.print("Loading "); out.println(classname); /* * Copies all fields into {@link #allocatedIDs} map. */ Field.setAccessible(fields, true); for (int i=fields.length; --i>=0;) { final Field field = fields[i]; final String key = field.getName(); try { final Object ID = field.get(null); if (ID instanceof Integer) { allocatedIDs.put((Integer)ID, key); } } catch (IllegalAccessException exception) { final File source = new File(classname.replace('.','/') + ".class"); warning(source, key, "Access denied", exception); } } } catch (ClassNotFoundException exception) { /* * 'VocabularyKeys.class' doesn't exist. This is okay (probably normal). * We will create 'VocabularyKeys.java' later using automatic key values. */ } } /** * Returns the class name for the keys. For example if {@code bundleClass} is * {@code "org.geotools.resources.i18n.Vocabulary"}, then this method returns * {@code "org.geotools.resources.i18n.VocabularyKeys"}. */ private static String toKeyClass(String bundleClass) { if (bundleClass.endsWith("s")) { bundleClass = bundleClass.substring(0, bundleClass.length()-1); } return bundleClass + "Keys"; } /** * Load the specified property file. */ private static Properties loadPropertyFile(final File file) throws IOException { final InputStream input = new FileInputStream(file); final Properties properties = new Properties(); properties.load(input); input.close(); return properties; } /** * Loads all properties from a {@code .properties} file. Resource keys are checked for naming * conventions (i.e. resources expecting some arguments must have a key name ending with * {@code "_$n"} where {@code "n"} is the number of arguments). This method transforms resource * values into legal {@link MessageFormat} patterns when necessary. * * @param file The properties file to read. * @throws IOException if an input/output operation failed. */ private void processPropertyFile(final File file) throws IOException { final Properties properties = loadPropertyFile(file); resources.clear(); for (final Map.Entry<Object,Object> entry : properties.entrySet()) { final String key = (String) entry.getKey(); final String value = (String) entry.getValue(); /* * Checks key and value validity. */ if (key.trim().length() == 0) { warning(file, key, "Empty key.", null); continue; } if (value.trim().length() == 0) { warning(file, key, "Empty value.", null); continue; } /* * Checks if the resource value is a legal MessageFormat pattern. */ final MessageFormat message; try { message = new MessageFormat(toMessageFormatString(value)); } catch (IllegalArgumentException exception) { warning(file, key, "Bad resource value", exception); continue; } /* * Checks if the expected arguments count (according to naming conventions) * matches the arguments count found in the MessageFormat pattern. */ final int argumentCount; final int index = key.lastIndexOf(ARGUMENT_COUNT_PREFIX); if (index < 0) { argumentCount = 0; resources.put(key, value); // Text will not be formatted using MessageFormat. } else try { String suffix = key.substring(index + ARGUMENT_COUNT_PREFIX.length()); argumentCount = Integer.parseInt(suffix); resources.put(key, message.toPattern()); } catch (NumberFormatException exception) { warning(file, key, "Bad number in resource key", exception); continue; } final int expected = message.getFormats().length; if (argumentCount != expected) { final String suffix = ARGUMENT_COUNT_PREFIX + expected; warning(file, key, "Key name should ends with \""+suffix+"\".", null); continue; } } /* * Allocates an ID for each new key. */ final String[] keys = resources.keySet().toArray(new String[resources.size()]); Arrays.sort(keys, this); int freeID = 0; for (int i=0; i<keys.length; i++) { final String key = keys[i]; if (!allocatedIDs.containsValue(key)) { Integer ID; do { ID = freeID++; } while (allocatedIDs.containsKey(ID)); allocatedIDs.put(ID, key); } } } /** * Write UTF file. Method {@link #processPropertyFile} should be invoked beforehand to * {@code writeUTFFile}. * * @param file The destination file. * @throws IOException if an input/output operation failed. */ private void writeUTFFile(final File file) throws IOException { final int count = allocatedIDs.isEmpty() ? 0 : Collections.max(allocatedIDs.keySet()) + 1; final DataOutputStream out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file))); out.writeInt(count); for (int i=0; i<count; i++) { final String value = (String) resources.get(allocatedIDs.get(i)); out.writeUTF((value!=null) ? value : ""); } out.close(); } /** * Changes a "normal" text string into a pattern compatible with {@link MessageFormat}. * The main operation consists of changing ' for '', except for '{' and '}' strings. */ private static String toMessageFormatString(final String text) { int level = 0; int last = -1; final StringBuilder buffer = new StringBuilder(text); search: for (int i=0; i<buffer.length(); i++) { // Length of 'buffer' will vary. switch (buffer.charAt(i)) { /* * Left and right braces take us up or down a level. Quotes will only be doubled * if we are at level 0. If the brace is between quotes it will not be taken into * account as it will have been skipped over during the previous pass through the * loop. */ case '{' : level++; last=i; break; case '}' : level--; last=i; break; case '\'': { /* * If a brace ('{' or '}') is found between quotes, the entire block is * ignored and we continue with the character following the closing quote. */ if (i+2<buffer.length() && buffer.charAt(i+2)=='\'') { switch (buffer.charAt(i+1)) { case '{': i+=2; continue search; case '}': i+=2; continue search; } } if (level <= 0) { /* * If we weren't between braces, we must double the quotes. */ buffer.insert(i++, '\''); continue search; } /* * If we find ourselves between braces, we don't normally need to double * our quotes. However, the format {0,choice,...} is an exception. */ if (last>=0 && buffer.charAt(last)=='{') { int scan=last; do if (scan>=i) continue search; while (Character.isDigit(buffer.charAt(++scan))); final String choice=",choice,"; final int end=scan+choice.length(); if (end<buffer.length() && buffer.substring(scan, end).equalsIgnoreCase(choice)) { buffer.insert(i++, '\''); continue search; } } } } } return buffer.toString(); } /** * Prints a message to the output stream. * * @param file File that produced the error, or {@code null} if none. * @param key Resource key that produced the error, or {@code null} if none. * @param message The message string. * @param exception An optional exception that is the cause of this warning. */ private void warning(final File file, final String key, final String message, final Exception exception) { out.print("ERROR "); if (file != null) { String filename = file.getPath(); if (filename.endsWith(PROPERTIES_EXT)) { filename = filename.substring(0, filename.length()-PROPERTIES_EXT.length()); } out.print('('); out.print(filename); out.print(')'); } out.print(": "); if (key != null) { out.print('"'); out.print(key); out.print('"'); } out.println(); out.print(message); if (exception != null) { out.print(": "); out.print(exception.getLocalizedMessage()); } out.println(); out.println(); out.flush(); } /** * Creates a source file for resource keys. * * @param bundleClass The resource bundle base class * (e.g. <code>{@linkplain org.geotools.resources.i18n.Vocabulary}.class}</code>). * @throws IOException if an input/output operation failed. */ private void writeJavaSource(final Class bundleClass) throws IOException { final String fullname = toKeyClass(bundleClass.getName()); final int packageEnd = fullname.lastIndexOf('.'); final String packageName = fullname.substring(0, packageEnd); final String classname = fullname.substring(packageEnd + 1); final File file = new File(sourceDirectory, "java/" + fullname.replace('.', '/') + ".java"); if (!file.getParentFile().isDirectory()) { warning(file, null, "Parent directory not found.", null); return; } final BufferedWriter out = new BufferedWriter(new OutputStreamWriter( new FileOutputStream(file), "UTF-8")); out.write("/*\n" + " * GeoTools - The Open Source Java GIS Toolkit\n" + " * http://geotools.org\n" + " * \n" + " * (C) 2003-2008, Open Source Geospatial Foundation (OSGeo)\n" + " * \n" + " * This library is free software; you can redistribute it and/or\n" + " * modify it under the terms of the GNU Lesser General Public\n" + " * License as published by the Free Software Foundation;\n" + " * version 2.1 of the License.\n" + " * \n" + " * This library is distributed in the hope that it will be useful,\n" + " * but WITHOUT ANY WARRANTY; without even the implied warranty of\n" + " * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU\n" + " * Lesser General Public License for more details.\n" + " * \n" + " * THIS IS AN AUTOMATICALLY GENERATED FILE. DO NOT EDIT!\n" + " * Generated with: org.geotools.resources.IndexedResourceCompiler\n" + " */\n"); out.write("package "); out.write(packageName); out.write(";\n\n\n"); out.write("/**\n" + " * Resource keys. This class is used when compiling sources, but\n" + " * no dependencies to {@code ResourceKeys} should appear in any\n" + " * resulting class files. Since Java compiler inlines final integer\n" + " * values, using long identifiers will not bloat constant pools of\n" + " * classes compiled against the interface, provided that no class\n" + " * implements this interface.\n" + " *\n" + " * @see org.geotools.resources.IndexedResourceBundle\n" + " * @see org.geotools.resources.IndexedResourceCompiler\n" + " * @source \u0024URL\u0024\n" + " */\n"); out.write("public final class "); out.write(classname); out.write(" {\n"); out.write(" private "); out.write(classname); out.write("() {\n"); out.write(" }\n"); final Map.Entry[] entries = allocatedIDs.entrySet().toArray(new Map.Entry[allocatedIDs.size()]); Arrays.sort(entries, this); for (int i=0; i<entries.length; i++) { out.write('\n'); final String key = (String) entries[i].getValue(); final String ID = entries[i].getKey().toString(); String message = (String) resources.get(key); if (message != null) { out.write(" /**\n"); while (((message=message.trim()).length()) != 0) { out.write(" * "); int stop = message.length(); if (stop > COMMENT_LENGTH) { stop = COMMENT_LENGTH; while (stop>20 && !Character.isWhitespace(message.charAt(stop))) { stop--; } } out.write(message.substring(0, stop).trim()); out.write('\n'); message = message.substring(stop); } out.write(" */\n"); } out.write(" public static final int "); out.write(key); out.write(" = "); out.write(ID); out.write(";\n"); } out.write("}\n"); out.close(); } /** * Compares two resource keys. Object {@code o1} and {@code o2} are usually {@link String} * objects representing resource keys (for example, "{@code MISMATCHED_DIMENSION}"), but * may also be {@link java.util.Map.Entry}. */ public int compare(Object o1, Object o2) { if (o1 instanceof Map.Entry) o1 = ((Map.Entry) o1).getValue(); if (o2 instanceof Map.Entry) o2 = ((Map.Entry) o2).getValue(); final String key1 = (String) o1; final String key2 = (String) o2; return key1.compareTo(key2); } /** * Scans the package for resources. * * @param sourceDirectory The base directory for {@code "java"} {@code "resources"} * sub-directories. The directory structure must be consistent with Maven conventions. * @param bundleClass The resource bundle base class * (e.g. <code>{@linkplain org.geotools.resources.i18n.Vocabulary}.class}</code>). * @param renumber {@code true} for renumbering all key values. * @param out The output stream for printing message. * @throws IOException if an input/output operation failed. */ private static void scanForResources(final File sourceDirectory, final Class<? extends IndexedResourceBundle> bundleClass, final boolean renumber, final PrintWriter out) throws IOException { final String fullname = bundleClass.getName(); final int packageEnd = fullname.lastIndexOf('.'); final String packageName = fullname.substring(0, packageEnd); final String classname = fullname.substring(packageEnd + 1); final String packageDir = packageName.replace('.', '/'); final File srcDir = new File(sourceDirectory, "java/" + packageDir); final File utfDir = new File(sourceDirectory, "resources/" + packageDir); if (!srcDir.isDirectory()) { out.print('"'); out.print(srcDir.getPath()); out.println("\" is not a directory."); return; } if (!utfDir.isDirectory()) { out.print('"'); out.print(utfDir.getPath()); out.println("\" is not a directory."); return; } IndexedResourceCompiler compiler = null; final File[] content = srcDir.listFiles(); File defaultLanguage = null; for (int i=0; i<content.length; i++) { final File file = content[i]; final String filename = file.getName(); if (filename.startsWith(classname) && filename.endsWith(PROPERTIES_EXT)) { if (compiler == null) { compiler = new IndexedResourceCompiler(sourceDirectory, bundleClass, renumber, out); } compiler.processPropertyFile(file); final String noExt = filename.substring(0, filename.length() - PROPERTIES_EXT.length()); final File utfFile = new File(utfDir, noExt + RESOURCES_EXT); compiler.writeUTFFile(utfFile); if (noExt.equals(classname)) { defaultLanguage = file; } } } if (compiler != null) { if (defaultLanguage != null) { compiler.resources.clear(); compiler.resources.putAll(loadPropertyFile(defaultLanguage)); } compiler.writeJavaSource(bundleClass); } } /** * Run the resource compiler. * * @param args The command-line arguments. * @param sourceDirectory The base directory for {@code "java"} {@code "resources"} * sub-directories. The directory structure must be consistent with Maven conventions. * @param resourcesToProcess The resource bundle base classes * (e.g. <code>{@linkplain org.geotools.resources.i18n.Vocabulary}.class}</code>). */ public static void main(String[] args, final File sourceDirectory, final Class<? extends IndexedResourceBundle>[] resourcesToProcess) { final Arguments arguments = new Arguments(args); final boolean renumber = arguments.getFlag("-renumber"); final PrintWriter out = arguments.out; args = arguments.getRemainingArguments(0); if (!sourceDirectory.isDirectory()) { out.print(sourceDirectory); out.println(" not found or is not a directory."); return; } for (int i=0; i<resourcesToProcess.length; i++) { try { scanForResources(sourceDirectory, resourcesToProcess[i], renumber, out); } catch (IOException exception) { out.println(exception.getLocalizedMessage()); } } out.flush(); } /** * Run the compiler for GeoTools resources. */ public static void main(final String[] args) { main(args, SOURCE_DIRECTORY, RESOURCES_TO_PROCESS); } }