# NameConverterWorker.java

    /*
    ** Module   : NameConverterWorker.java
    ** Abstract : Exports name conversion methods to the pattern engine
    **
    ** Copyright (c) 2005-2025, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- --JPRM-- -----------------------------------Description-----------------------------------
    ** 001 ECF 20050418   @20755 Created initial version. Exposes name
    **                           conversion constants and methods for use
    **                           within the pattern engine.
    ** 002 ECF 20050427   @20901 Enhancements. Shortened and lowercased
    **                           constant names; namespace feature makes the
    **                           shorter names adequately unique. Added an
    **                           automatic report generation feature. Modified
    **                           user function signatures to match constructor
    **                           changes in NameConverter.
    ** 003 ECF 20050430   @20970 Shortened user function names to take
    **                           advantage of namespace support. Also minor
    **                           improvements to report generation.
    ** 004 ECF 20050526   @21290 Changes to support new expression engine
    **                           implementation. Changed library registration
    **                           mechanism based on superclass' modifications
    **                           to support single library per pattern worker
    **                           limit.
    ** 005 ECF 20050630   @21596 Refactored conversion API. Eliminated the
    **                           multi-step conversion idiom in favor of a
    **                           one-step mechanism in which custom
    **                           replacements can be provided using varargs.
    ** 006 ECF 20050714   @21694 Added variant of convert user function. New
    **                           version accepts a map of replacements which
    **                           are loaded in addition to the defaults before
    **                           conversion is performed.
    ** 007 GES 20051028   @23142 Added generateBeanName().
    ** 008 GES 20060311   @24976 Added load/reload of named match lists.
    ** 009 ECF 20060326   @25239 Fixed NPE in recordHistory() method.
    ** 010 GES 20090422   @41896 Converted to standard string formatting.
    ** 011 GES 20090424   @41931 Import change.
    ** 012 GES 20090518   @42381 Import change.
    ** 013 GES 20091117   @44420 Removed unused code.
    ** 014 GES 20140118          Added convertPackage() static method.
    ** 015 OM  20150525          Added resetDialects(String) to library exposed methods.
    **                           Upgraded code to Java7 (generics, foreach, unboxing).
    ** 016 OM  20170207          Added TYPE_PROPERTY for special case of DMO property names.
    ** 017 OM  20171108          Avoided name collision between tables and sequences.
    ** 018 OM  20190829          Custom handling for hidden BEFORE-BUFFER fields.
    ** 019 ECF 20200906          New ORM implementation.
    **     OM  20210129          Avoided NPE for incorrectly initialized tables.
    **     IAS 20210219          Generic argument added.
    **     OM  20210908          When converting _temp or _meta database name, the name is kept.
    **     OM  20211020          Added _DATASOURCE_ROWID property.
    **     CA  20211214          The AST manager plugin must be context-local, for runtime conversion, as the
    **                           InMemoryRegistryPlugin is not thread-safe.
    **                           All worker's state must be context-local, for runtime conversion to work in
    **                           multi-context mode, as the pattern workers are singletons.
    **     CA  20220202          Fixed a regression in CA/20211214, the defaultConverter must be re-initialized on
    **                           each instance of this worker, as the defaults may have changed.
    **     OM  20220212          Use ReservedProperty static method for testing reserved properties names.
    **     OM  20220721          In case of TYPE_DENORMALIZED_PROPERTY the last _ (index separator) is preserved.
    **     ECF 20220514          Reworked to produce separate schema name reports for multiple schema inputs, and
    **                           to release memory after each AST is processed.
    **     ECF 20220516          Added support for minimal change and verbatim SQL name conversion modes.
    **     OM  20230125          Avoid collisions of index names with SQL reserved keywords.
    ** 020 CA  20241119          A DMO alias must not emit as a FQL reserved keyword.
    ** 021 CA  20250401          Any possible Java import from core FWD packages can result in a 'schemaconflict',
    **                           so the DMO name will be qualified if they match a simple core class name.
    ** 022 CA  20250515          Use legacy names for the proxies only if they can be used as-is as Java
    **                           identifiers.
    */

    /*
    ** This program is free software: you can redistribute it and/or modify
    ** it under the terms of the GNU Affero General Public License as
    ** published by the Free Software Foundation, either version 3 of the
    ** License, or (at your option) any later version.
    **
    ** This program 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 Affero General Public License for more details.
    **
    ** You may find a copy of the GNU Affero GPL version 3 at the following
    ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
    **
    ** Additional terms under GNU Affero GPL version 3 section 7:
    **
    **   Under Section 7 of the GNU Affero GPL version 3, the following additional
    **   terms apply to the works covered under the License.  These additional terms
    **   are non-permissive additional terms allowed under Section 7 of the GNU
    **   Affero GPL version 3 and may not be removed by you.
    **
    **   0. Attribution Requirement.
    **
    **     You must preserve all legal notices or author attributions in the covered
    **     work or Appropriate Legal Notices displayed by works containing the covered
    **     work.  You may not remove from the covered work any author or developer
    **     credit already included within the covered work.
    **
    **   1. No License To Use Trademarks.
    **
    **     This license does not grant any license or rights to use the trademarks
    **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
    **     of Golden Code Development Corporation. You are not authorized to use the
    **     name Golden Code, FWD, or the names of any author or contributor, for
    **     publicity purposes without written authorization.
    **
    **   2. No Misrepresentation of Affiliation.
    **
    **     You may not represent yourself as Golden Code Development Corporation or FWD.
    **
    **     You may not represent yourself for publicity purposes as associated with
    **     Golden Code Development Corporation, FWD, or any author or contributor to
    **     the covered work, without written authorization.
    **
    **   3. No Misrepresentation of Source or Origin.
    **
    **     You may not represent the covered work as solely your work.  All modified
    **     versions of the covered work must be marked in a reasonable way to make it
    **     clear that the modified work is not originating from Golden Code Development
    **     Corporation or FWD.  All modified versions must contain the notices of
    **     attribution required in this license.
    */

    package com.goldencode.p2j.convert;

    import java.io.*;
    import java.util.*;
    import com.goldencode.ast.*;
    import com.goldencode.p2j.persist.*;
    import com.goldencode.p2j.persist.orm.*;
    import com.goldencode.p2j.schema.*;
    import com.goldencode.p2j.security.*;
    import com.goldencode.util.*;
    import com.goldencode.p2j.cfg.*;
    import com.goldencode.p2j.pattern.*;

    /**
     * Exports symbol name conversion functionality to the pattern matching
     * engine for use within conversion rulesets. All symbol name conversion
     * work is delegated to the {@link NameConverter} class.
     * <p>
     * An automatic name conversion reporting facility can be triggered from a
     * ruleset. This mode is disabled by default. It is enabled with the {@link
     * NameConverterWorker.Library#setReportMode} user function. As this worker
     * converts names, a formatted history entry for each conversion is recorded.
     * At the end of processing (i.e., in a post-rule ruleset), a text report is
     * generated with {@link NameConverterWorker.Library#generateReport}. The
     * report is written to a file which the caller specifies.
     */
    public class NameConverterWorker
    extends AbstractPatternWorker
    implements MatchPhraseConstants
    {
       /** Format specifier for the column report output. */
       private static final String RPT_FMT = "%-17s %-16s %-28s %-16s %s";

       /** Map of conversion type literals, exposed by their constant names */
       private static final Map<String, Long> TYPE_MAP = new HashMap<>();

       /** Context local work area. */
       private static final ContextLocal<WorkArea> context = new ContextLocal<WorkArea>()
       {
          protected WorkArea initialValue()
          {
             return new WorkArea();
          }
       };

       // Populate the conversion type literal map.
       static
       {
          TYPE_MAP.put("variable",              (long) TYPE_VARIABLE);
          TYPE_MAP.put("method",                (long) TYPE_METHOD);
          TYPE_MAP.put("constant",              (long) TYPE_CONSTANT);
          TYPE_MAP.put("class",                 (long) TYPE_CLASS);
          TYPE_MAP.put("database",              (long) TYPE_DATABASE);
          TYPE_MAP.put("table",                 (long) TYPE_TABLE);
          TYPE_MAP.put("column",                (long) TYPE_COLUMN);
          TYPE_MAP.put("index",                 (long) TYPE_INDEX);
          TYPE_MAP.put("property",              (long) TYPE_PROPERTY);
          TYPE_MAP.put("dmo_alias",             (long) TYPE_DMO_ALIAS);
          TYPE_MAP.put("denormalized_property", (long) TYPE_DENORMALIZED_PROPERTY);
          TYPE_MAP.put("standard",              (long) STANDARD);
          TYPE_MAP.put("custom",                (long) CUSTOM);
          TYPE_MAP.put("preserve",              (long) PRESERVE);
          TYPE_MAP.put("all",                   (long) ALL);
       }

       /**
        * Default constructor which defines the symbol libraries to be registered.
        */
       public NameConverterWorker()
       {
          super();

          setLibrary(new Library());

          // re-initialize the default context-local NameConverter, as the defaults may have changed.  this is
          // required for static conversion, as the datanames.xml is loaded when processing schema DMOs.  for
          // dynamic conversion, datanames.xml has no benefit, and is OK to not be used.
          context.get().defaultConverter = new NameConverter();
       }

       /**
        * This method is called each time the pattern engine needs to resolve
        * a string constant into a numeric, boolean, or string literal value.
        * In this case, it is used to resolve the conversion type literals to
        * <code>Long</code> values, so that the conversion types can be
        * referenced by their {@link NameConverter} constant names from within
        * within user expressions.
        * @param   constant
        *          A string representing a conversion type constant to be
        *          resolved.
        *
        * @return  A <code>Long</code> initialized to the corresponding constant
        *          value (values in <code>NameConverter</code> class), or
        *          <code>null</code> if <code>constant</code> was not recognized
        *          as a placeholder for such a literal.
        */
       public Object resolveConstant(String constant)
       {
          return TYPE_MAP.get(constant.toLowerCase());
       }

       /**
        * Called whenever the pattern engine visits a new AST, this method
        * prepares the history report facility to receive entries for names
        * converted while processing the AST. The history list is indexed in
        * a map by the filename of the AST being visited.
        *
        * @param   ast
        *          AST about to be processed by the pattern engine.
        */
       @Override
       public void visitAst(Aast ast)
       {
          String filename = ast.getFilename();
          if (filename == null)
          {
             // this is the dynamic conversion case. The database/source is not available.
             filename = "";
          }
          else
          {
             filename = filename.toLowerCase();
          }

          WorkArea wa = context.get();

          wa.tables.computeIfAbsent(filename, k -> new HashSet<>());

          if (wa.history != null)
          {
             wa.history.computeIfAbsent(filename, k -> new ArrayList<>());
          }
       }

       /**
        * Upon leaving an AST, if we are in report mode, reset name conversion history to allow data collection
        * for the next (if any) AST to be processed.
        *
        * @param   ast
        *          Copy AST just processed.
        */
       @Override
       public void leaveAst(Aast ast)
       {
          WorkArea wa = context.get(false);
          if (wa != null && wa.history != null)
          {
             ((Library) getLibrary()).setReportMode(true);
          }
       }

       /**
        * Helper method which records information about a name conversion.
        * The type of conversion, original name, resulting name, and token
        * type of the AST from which the name originated are written to a
        * formatted string. The string is stored as a history entry in a list
        * within the history report map. The list is indexed by the filename
        * of the AST currently being processed.
        *
        * @param   converter
        *          Object which performed the name conversion.
        * @param   result
        *          The name resulting from the conversion.
        * @param   detail
        *          Additional notes.
        */
       private void recordHistory(NameConverter converter, String result, String detail)
       {
          WorkArea wa = context.get();

          if (wa.history == null)
          {
             return;
          }

          if (detail == null)
          {
             detail = "";
          }

          Aast ast = getResolver().getSourceAst();
          String filename = ast.getFilename().toLowerCase();
          List<String> list = wa.history.get(filename);
          Object[] args =
          {
             ast.toString(),
             converter.describeConversion(),
             result,
             converter.getName(),
             detail,
          };
          list.add(String.format(RPT_FMT, args));
       }

       /**
        * Class whose instance is actually registered with the pattern engine's
        * symbol resolver as the primary library of name conversion user
        * functions.
        */
       public class Library
       {
          /**
           * Check if this simple Java name can collide with one of the simple names of the FWD classes which can
           * be part of the imports emitted during conversion.
           *
           * @param    name
           *           The simple java name.
           *
           * @return   See above.
           */
          public boolean coreSimpleNameConflict(String name)
          {
             return NameConverter.coreSimpleNameConflict(name);
          }

          /**
           * Reset the reserved keyword exclusion list.
           *
           * Releases the old list and loads the keyword lists according to dialects specified in the
           * configuration file for current {@code schema}. The {@link NameConverter} will lookup in
           * {@code p2j.cfg.xml} for the schema name and use the {@code getReservedKeywords()} static
           * method of each defined {@link com.goldencode.p2j.persist.dialect.Dialect} to create
           * the keyword exclusion list for each schema.
           *
           * This method must be called for each processed database because each one can have its
           * own list of dialects, otherwise the generated schema won't be compatible with respective
           * dialects or the {@link NameConverter} will impose unneeded constraints to identifiers
           * that matched keywords from other dialects.
           *
           * @param   schema
           *          The schema whose dialects are used for configuring the keyword compatibility
           *          list.
           */
          public void resetDialects(String schema)
          {
             NameConverter.resetDialects(schema);
          }

          /**
           * Determine if the given name can be used as a Java identifier.  If it can be used, determine if the
           * name is not also a keyword conflict.
           *
           * @param    name
           *           The name to check.
           *
           * @return   <code>true</code> if the name can be used as a Java identifier.
           */
          public boolean isValidJavaIdentifier(String name)
          {
             for (int i = 0; i < name.length(); i++)
             {
                char c = name.charAt(i);
                if (!((i == 0 && Character.isJavaIdentifierStart(c)) ||
                       (i > 0 && Character.isJavaIdentifierPart(c))))
                {
                   return false;
                }
             }

             return name.equals(NameConverter.resolvePossibleKeywordConflict(name, TYPE_METHOD));
          }

          /**
           * Convert the given name using the specified conversion type and using
           * all defaults.
           *
           * @param   name
           *          Symbol name to be converted.
           * @param   type
           *          Type of symbol conversion to be applied to
           *          <code>name</code>. Must be one of the {@link
           *          MatchPhraseConstants} constants.
           *
           * @return  The converted symbol name.
           */
          public String convert(String name, int type)
          {
             return convert(name, type, null, (Map<String, String>) null);
          }

          /**
           * Convert the given name using the specified conversion type. Allows
           * overriding or additional match/replacement pairs to be provided.
           *
           * @param   name
           *          Symbol name to be converted.
           * @param   type
           *          Type of symbol conversion to be applied to
           *          <code>name</code>. Must be one of the {@link
           *          MatchPhraseConstants} constants.
           * @param   detail
           *          Optional, additional information to association with this
           *          conversion in the conversion report. May be
           *          <code>null</code>. Ignored if not in reporting mode.
           * @param   pairs
           *          An even number of strings which represent key/value pairs.
           *          These pairs are used to add to or override existing match
           *          phrase replacement entries, including those provided by
           *          any hints. If empty, only the default entries and those
           *          provided by hints are used.
           *
           * @return  The converted symbol name.
           */
          public String convert(String name, int type, String detail, Object[] pairs)
          {
             if (pairs.length > 0)
             {
                return convert(name, type, MatchPhraseConstants.ALL, true, detail, pairs);
             }

             WorkArea wa = context.get();

             String result = wa.defaultConverter.convert(name, type);
             recordHistory(wa.defaultConverter, result, detail);

             return result;
          }

          /**
           * Convert the given name using the specified conversion type and
           * phrase map. The phrases in <code>replacements</code> are in addition
           * to the default conversion phrases, and may override defaults.
           *
           * @param   name
           *          Symbol name to be converted.
           * @param   type
           *          Type of symbol conversion to be applied to {@code name}. Must be one of the {@link
           *          MatchPhraseConstants} constants.
           * @param   detail
           *          Optional, additional information to association with this conversion in the conversion
           *          report. May be {@code null}. Ignored if not in reporting mode.
           * @param   replacements
           *          Replacement phrase mappings, where the key is the match text, and the value is the
           *          replacement text. If {@code null} or empty, only the default phrases are used.
           *
           * @return  The converted symbol name.
           */
          public String convert(String name, int type, String detail, Map<String, String> replacements)
          {
             if (type == TYPE_DENORMALIZED_PROPERTY)
             {
                // is a denormalized property name is requested, attempt to split the name in order to extract the
                // possible index of the property from the name before processing it as a normal property. On
                // return the index is added back and the underscore separator is preserved

                int k = name.lastIndexOf('_');
                if (k > 0 && k < name.length() - 1)
                {
                   try
                   {
                      String idxStr = name.substring(k + 1);
                      Integer.parseInt(idxStr); // use the string downstream but validate that this is an integer
                      String rootName = name.substring(0, k);
                      String ret = convert(rootName, TYPE_PROPERTY, detail, replacements);
                      return ret + "_" + idxStr; // restore the index prefixed by the underscore separator
                   }
                   catch (NumberFormatException e)
                   {
                      // invalid number, just abort and return as expected
                   }
                }

                // downgrade to normal property
                type = TYPE_PROPERTY;
             }

             if (type == TYPE_DATABASE &&
                 (SchemaDictionary.TEMP_TABLE_DB.equals(name) || SchemaDictionary.META_DB.equals(name)))
             {
                return name; // keep the _temp/_meta database names
             }

             WorkArea wa = context.get();
             NameConverter converter = wa.defaultConverter;
             if (replacements != null && !replacements.isEmpty())
             {
                converter = new NameConverter();

                for (Map.Entry<String, String> next : replacements.entrySet())
                {
                   converter.putReplacement(next.getKey(), next.getValue());
                }
             }

             String result = converter.convert(name, type);

             if (type == TYPE_TABLE) // this includes SEQUENCES, too
             {
                Aast ast = getResolver().getSourceAst();
                String filename = ast.getFilename().toLowerCase();
                Set<String> tableSet = wa.tables.get(filename);
                if (tableSet.contains(result))
                {
                   // we have a conflict. Try to use a incrementing counter to make result unique
                   // normally we don't expect more than one of these collisions for each name.
                   int k = 1;
                   while (tableSet.contains(result + "_" + k))
                   {
                      k++;
                   }
                   result += "_" + k;
                }

                // reserve it into SQL tables namespace:
                tableSet.add(result);
             }

             recordHistory(converter, result, detail);

             return result;
          }

          /**
           * Convert the given name using the specified conversion type. Allows
           * overriding or additional match/replacement pairs to be provided,
           * and to select a custom set of match phrases from the current match
           * phrase dictionary. Also allows replacement mode to be set.
           *
           * @param   name
           *          Symbol name to be converted.
           * @param   type
           *          Type of symbol conversion to be applied to
           *          <code>name</code>. Must be one of the {@link
           *          MatchPhraseConstants} <code>TYPE_*</code> constants.
           * @param   phrases
           *          A bitwise-OR'd combination of {@link MatchPhraseConstants}
           *          flags indicating which set of default match phrase entries
           *          to load from the project-specific match phrase dictionary.
           * @param   replace
           *          If <code>true</code>, any token from the match phrase
           *          entries defined by <code>phrases</code> which is found
           *          within <code>name</code> is replaced with its designated
           *          replacement string. If <code>false</code>, only tokens
           *          from the standard entries (if <code>phrases</code>
           *          includes the constant <code>STANDARD</code>) are replaced.
           *          All other tokens are recognized as separate syllables,
           *          but are not replaced.
           * @param   detail
           *          Optional, additional information to association with this
           *          conversion in the conversion report. May be
           *          <code>null</code>. Ignored if not in reporting mode.
           * @param   pairs
           *          An even number of strings which represent key/value pairs.
           *          These pairs are used to add to or override existing match
           *          phrase replacement entries, including those provided by
           *          any hints. If empty, only the default entries and those
           *          provided by hints are used.
           *
           * @return  The converted symbol name.
           */
          public String convert(String name,
                                int type,
                                int phrases,
                                boolean replace,
                                String detail,
                                Object[] pairs)
          {
             if (ReservedProperty.isReservedProperty(name))
             {
                switch (name)
                {
                   case Buffer.__ERROR_FLAG__:        return Buffer.ERROR_FLAG_FIELD;
                   case Buffer.__ORIGIN_ROWID__:      return Buffer.ORIGIN_ROWID_FIELD;
                   case Buffer.__DATA_SOURCE_ROWID__: return Buffer.DATA_SOURCE_ROWID_FIELD;
                   case Buffer.__ERROR_STRING__:      return Buffer.ERROR_STRING_FIELD;
                   case Buffer.__AFTER_ROWID__:       return Buffer.AFTER_ROWID_FIELD;
                   case Buffer.__ROW_STATE__:         return Buffer.ROW_STATE_FIELD;
                }
             }

             int len = pairs.length;
             if (len % 2 != 0)
             {
                throw new IllegalArgumentException(
                   "A replacement value is missing for key:  " + pairs[len - 1]);
             }

             NameConverter converter = new NameConverter(phrases, replace);
             switch (type)
             {
                case TYPE_TABLE:
                case TYPE_COLUMN:
                case TYPE_INDEX:
                   converter.setSqlOverride(context.get().defaultConverter.getSqlOverride());
                   break;
             }
             for (int i = 0; i < len; i += 2)
             {
                converter.putReplacement((String) pairs[i], (String) pairs[i + 1]);
             }

             String result = converter.convert(name, type);
             recordHistory(converter, result, detail);

             return result;
          }

          /**
           * Lowercase and split the input into individual package names, then ensure that each
           * one is not a conflict with a Java reserved word. This will append an underscore to any
           * conflicting package name.
           *
           * @param    pkg
           *           A candidate Java package name, possibly including multiple period-separated
           *           directories.
           *
           * @return   The reassembled and non-conflicting result.
           */
          public String convertPackage(String pkg)
          {
             return NameConverter.convertPackage(pkg);
          }

          /**
           * Create a (mostly) Java-bean compatible getter or setter name for a
           * given field.  If the field is a <code>boolean</code> and a getter is
           * requested, then the prefix will be "is", otherwise getters are prefixed
           * with "get".  All setters are prefixed with "set".  The name of the
           * field always has its first character uppercased which is slightly
           * different than how Sun defines the standard.
           *
           * @param    field
           *           The name to use as the basis, must be non-null and non-empty.
           * @param    getter
           *           If <code>true</code> the resulting bean name will be a
           *           getter.
           * @param    bool
           *           If <code>getter</code> is <code>true</code> and this is
           *           <code>true</code> then the resulting bean name will prefixed
           *           with "is" instead of "get".  Ignored for setters.
           *
           * @return   The resulting getter or setter name.
           *
           * @throws   IllegalArgumentException
           *           If the field name is <code>null</code> or is the empty
           *           string.
           */
          public String generateBeanName(String field, boolean getter, boolean bool)
          throws IllegalArgumentException
          {
             return NameConverter.generateBeanName(field, getter, bool);
          }

          /**
           * Turn automatic name conversion report mode on or off. Enabling
           * report mode simply stores name conversion history entries, but the
           * report must be generated explicitly after processing is complete,
           * typically within a <code>post-rule</code>. Actual report generation
           * is triggered with a call to the {@link #generateReport} user
           * function.
           * <p>
           * Report mode is <em>disabled</em> by default when this pattern worker
           * is first created.
           *
           * @param   enable
           *          <code>true</code> to enable report mode; <code>false</code>
           *          to disable it.
           */
          public void setReportMode(boolean enable)
          {
             context.get().history = enable ? new LinkedHashMap<>() : null;
          }

          /**
           * Set the constant describing the active SQL name conversion override.
           *
           * @param   sqlOverride
           *          Constant for the desired SQL name conversion override.
           *
           * @see     MatchPhraseConstants
           */
          public void setSqlOverride(int sqlOverride)
          {
             context.get().defaultConverter.setSqlOverride(sqlOverride);
          }

          /**
           * Generate a name conversion report and write it to the specified
           * file. A report is only generated if report mode was enabled with
           * the {@link #setReportMode} user function previous to name
           * conversion. All names converted from the time report mode was
           * enabled to the time this method is called are included in the
           * report.
           *
           * @param   filename
           *          Report destination filename. If not an absolute path, it
           *          is considered relative to the working directory (i.e.,
           *          no P2J_HOME normalization is performed).
           *
           * @return  <code>true</code> if report mode is enabled, else
           *          <code>false</code>.
           *
           * @throws  IOException
           *          if any I/O errors occur while persisting the report.
           */
          public boolean generateReport(String filename)
          throws IOException
          {
             WorkArea wa = context.get();

             if (wa.history == null)
             {
                return false;
             }

             BufferedWriter bw = null;

             try
             {
                bw = new BufferedWriter(new FileWriter(filename));
                for (String key : wa.history.keySet())
                {
                   List<String> list = wa.history.get(key);

                   // Filename.
                   bw.write(key);
                   bw.write(":");
                   bw.newLine();
                   bw.newLine();

                   // Headings.
                   Object[] args =
                   {
                      StringHelper.centerText("TOKEN", 17),
                      StringHelper.centerText("TYPE", 16),
                      StringHelper.centerText("CONVERTED", 28),
                      StringHelper.centerText("ORIGINAL", 16),
                      StringHelper.centerText("NOTES", 16),
                   };
                   bw.write(String.format(RPT_FMT, args));
                   bw.newLine();
                   String sep16 = StringHelper.repeatChar('-', 16);
                   String sep28 = StringHelper.repeatChar('-', 28);
                   String sep17 = StringHelper.repeatChar('-', 17);
                   args[0] = sep17;
                   args[1] = sep16;
                   args[2] = sep28;
                   args[3] = sep16;
                   args[4] = sep16;
                   bw.write(String.format(RPT_FMT, args));
                   bw.newLine();

                   // Content.
                   for (String line : list)
                   {
                      bw.write(line);
                      bw.newLine();
                   }

                   bw.newLine();
                }
             }
             finally
             {
                if (bw != null)
                {
                   bw.close();
                }
             }

             return true;
          }
       }

       /**
        * Context local work area.
        */
       private static class WorkArea
       {
          /** The default converter. */
          private NameConverter defaultConverter = new NameConverter();

          /** History lists of name conversions, indexed by AST filename */
          private Map<String, List<String>> history = null;

          /**
           * Keeps the set of SQL table (and sequence) names for each schema. This is useful for checking
           * whether a converted name is not already 'taken' within the same schema. Although this should
           * not happen within the table name space (their names are unique in P4GL so they will be also
           * unique in SQL), when adding sequences, collisions are possible. This is because in P4GL they
           * have a dedicated namespace, but in SQL they share it with normal tables.
           */
          private final Map<String, Set<String>> tables = new HashMap<>();
       }
    }