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<>();
   }
}