NumberFormatParser.java

/*
** Module   : NumberFormatParser.java
** Abstract : Base class for Progress number format parsers/formatters.
**
** Copyright (c) 2021, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description---------------------------------
** 001 VVT 20210417 Created initial version.
**     VVT 20210426 Format of all calbacks unified. The formatting algorithm fixed in multiple
**                  places, and now passes all unit tests.
*/
/*
** 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.util;

import java.util.regex.*;

/**
 * Base class for Progress number format parsers/formatters.
 * 
 * The {@link #parse(String)} method is responsible for checking the
 * Progress date format validity.
 * 
 * Also this class provides a set of callback methods,
 * each callback is called when the corresponding format component is parsed.
 * 
 * By default, all callbacks do nothing, they are expected to be re-defined in subclasses.
 */
public class NumberFormatParser
{
   /** Pre-compiled regex pattern to right trim format string */
   private static final Pattern RIGHT_SPACES_PATTERN = Pattern.compile("\\s*$");

   /**
    * Parse number format, call callbacks while parsing.
    * 
    * @param format
    *        the format string to parse
    * 
    * @throws ErrorConditionException
    *         in case the number format is invalid
    */
   public void parse(final String format) throws ErrorConditionException
   {
      // Trim the format right.
      final String fmt = RIGHT_SPACES_PATTERN.matcher(format).replaceFirst("");

      // The length of format string
      final int len = fmt.length();

      // The current character read
      char c = 0;

      // The current index into format string
      int formatIdx = 0;

      // True if at least one left sign parenthesis exist
      boolean leftParenthesisExists = false;

      // Collect the optional user-specified prefix: leading
      // characters that are not valid formatting characters.
      // This string can also include any number of left parenthesizes.
      endLeft:
      for (;; formatIdx++)
      {
         if (formatIdx == len)
         {
            genNoDigitsError(fmt);
            return;
         }

         c = fmt.charAt(formatIdx);
         switch (c)
         {
            case ',':
            case '.':
            case '0':
            case '1':
            case '2':
            case '3':
            case '4':
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
            case '>':
            case 'z':
            case 'Z':
            case '*':
            case '+':
            case '-':
               // A 'digit' character found.
               break endLeft;

            case '(':
               leftParenthesisExists = true;
               visitSign(formatIdx, formatIdx + 1);
               break;

            default:
               visitFillChar(c);
         }
      }

      // True is '+' or '-' character was parsed
      boolean plusOrMinusExists = false;

      // scan optional '+' or '-' sign
      if (!leftParenthesisExists)
      {
         switch (c)
         {
            case '+':
            case '-':
               plusOrMinusExists = true;
               visitSign(formatIdx, formatIdx + 1);
               formatIdx++;
               break;
            default:
         }
      }

      if (formatIdx == len)
      {
         genNoDigitsError(fmt);
         return;
      }

      // The character after '+' or '-' must be a 'digit'.
      if (plusOrMinusExists)
      {
         switch (fmt.charAt(formatIdx))
         {
            case '9':
            case '*':
            case ',':
            case '.':
            case '>':
            case 'z':
            case 'Z':
               break;

            default:
               ErrorManager.genInvalidCharError(formatIdx, fmt);
               return;
         }
      }

      // Scan the 'digits' section

      // The start of currently parsed component
      int sectionStart = formatIdx;

      // Count the '>' characters in the left digits section,
      // this should match the number of '<' in the right digits section
      int leftBalance = 0;

      // At least one 'digit' character found flag
      boolean anyDigit = false;

      // 2. Scan optional left balance and separators portion: [>,]*, update digits and leftBalance counters
      endLeftBalance:
      for (;; formatIdx++)
      {
         if (formatIdx == len)
         {
            if (!anyDigit)
            {
               genNoDigitsError(fmt);
               return;
            }

            break;
         }

         c = fmt.charAt(formatIdx);
         switch (c)
         {
            case '>':
               leftBalance++;
               anyDigit = true;
               break;

            case ',':
               leftBalance++;
               break;

            default:
               break endLeftBalance;
         }
      }

      // 3. Scan optional left [9zZ*,]* portion
      endDigitsSection:
      for (; formatIdx < len; formatIdx++)
      {
         switch (fmt.charAt(formatIdx))
         {
            case '9':
            case 'z':
            case 'Z':
            case '*':
               anyDigit = true;
               break;

            case ',':
               break;

            default:
               break endDigitsSection;
         }
      }

      if (formatIdx > sectionStart)
      {
         visitLeftDigits(sectionStart, formatIdx);
      }

      if (formatIdx == len)
      {
         if (!anyDigit)
         {
            genNoDigitsError(fmt);
         }

         return;
      }

      // 4. Scan optional right side part
      switch (fmt.charAt(formatIdx))
      {
         case '.':
            formatIdx++;

            sectionStart = formatIdx;

            int separators = 0;
            // Scan optional digits and FORMAT_GROUP_SEP [9zZ*,]*
            endDigitsSection:
            for (; formatIdx < len; formatIdx++)
            {
               c = fmt.charAt(formatIdx);
               if (sectionStart == formatIdx)
               {
                  // First digit character after '.' can only be '9'
                  switch (c)
                  {
                     case 'z':
                     case 'Z':
                     case '*':
                     case ',':
                        ErrorManager.genInvalidCharError(formatIdx, fmt);
                        return;
                     default:
                  }
               }

               switch (c)
               {
                  case '9':
                  case 'z':
                  case 'Z':
                  case '*':
                     anyDigit = true;
                     break;

                  case ',':
                     separators++;
                     break;

                  default:
                     break endDigitsSection;
               }
            }

            // Scan optional right balance [&lt,]*, check the right balance is no more than the left one.
            endRightBanance:
            for (int rightBalance = 0; formatIdx < len; formatIdx++)
            {
               switch (fmt.charAt(formatIdx))
               {
                  case ',':
                     separators++;
                     // $FALL-THROUGH$
                  case '<':
                     rightBalance++;

                     if (rightBalance > leftBalance)
                     {
                        ErrorManager.genInvalidCharError(formatIdx, fmt);
                        return;
                     }
                     break;
                  default:
                     break endRightBanance;
               }
            }

            // Note: we need to visit the callback method event if no digits are in the right part.
            visitRightDigits(sectionStart, formatIdx, separators);

            break;

         case '<':
            // If there is no right part, there must be no right balance either.
            ErrorManager.genInvalidCharError(formatIdx, fmt);
            return;

         default:
      }

      if (formatIdx == len)
      {
         if (!anyDigit)
         {
            genNoDigitsError(fmt);
         }

         return;
      }

      c = fmt.charAt(formatIdx);

      if (!leftParenthesisExists)
      {
         switch (c)
         {
            case '-':
            case '+':
               if (plusOrMinusExists || leftParenthesisExists)
               {
                  ErrorManager.genInvalidCharError(formatIdx, fmt);
                  return;
               }

               visitSign(formatIdx, formatIdx + 1);
               formatIdx++;
               break;

            default:
               switch (fmt.substring(formatIdx).toLowerCase())
               {
                  // FIXME: record these in callbacks
                  case "dr":
                     visitSign(formatIdx, formatIdx + 2);
                     formatIdx += 2;
                     break;
                  case "cr":
                     visitSign(formatIdx, formatIdx + 2);
                     formatIdx += 2;
                     break;
                  case "db":
                     visitSign(formatIdx, formatIdx + 2);
                     formatIdx += 2;
                     break;
                  default:
               }
         }
      }

      // Scan the rest of format as right user section.
      sectionStart = formatIdx;

      // Scan optional right sign
      if (leftParenthesisExists)
      {
         // If at least one left paren is present, it MUST be the right parent here.
         if (c != ')')
         {
            ErrorManager.genInvalidCharError(formatIdx, fmt);
            return;
         }

         visitSign(formatIdx, formatIdx + 1);

         // FIXME: record the right paren using a callback
         formatIdx++;
      }

      for (; formatIdx < len; formatIdx++)
      {
         c = fmt.charAt(formatIdx);
         switch (c)
         {
            case '>':
            case ',':
            case '*':
            case 'z':
            case 'Z':
            case '+':
            case '-':
               if (formatIdx > sectionStart)
               {
                  formatIdx--;
               }
               // $FALL-THROUGH$
            case '0':
            case '1':
            case '2':
            case '3':
            case '4':
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
            case ')':
            case '(':
               ErrorManager.genInvalidCharError(formatIdx, fmt);
               return;

            default:
               visitFillChar(c);
         }
      }

      // There must be at least one digit in the format.
      if (!anyDigit)
      {
         genNoDigitsError(fmt);
         return;
      }
   }

   /**
    * Non-empty left digits section was parsed.
    * <p>
    * The default implementation does nothing.
    *  
    * @param startIdx
    *        the section start index in format string, zero-based, inclusive
    * @param endIdx
    *        the section end index in format string, zero-based, exclusive
    */
   protected void visitLeftDigits(final int startIdx, final int endIdx)
   {
      // no-op
   }

   /**
    * Right digits section was parsed (may be empty).
    * <p>
    * The default implementation does nothing.
    *  
    * @param startIdx
    *        the section start index in format string, zero-based, inclusive
    * @param endIdx
    *        the section end index in format string, zero-based, exclusive
    * @param separators
    *        the number of digit separator ',' character in right digits
    */
   protected void visitRightDigits(int startIdx, int endIdx, int separators)
   {
      // no-op
   }

   /**
    * A sign spec was parsed.
    * <p>
    * Sign specs are: '+', '-', '(', ')', DR, CR and DB.
    * <p>
    * The default implementation does nothing.
    * 
    * @param startIdx
    *        the section start index in format string, zero-based, inclusive
    * @param endIdx
    *        the section end index in format string, zero-based, exclusive
    */
   protected void visitSign(final int startIdx, final int endIdx)
   {
      // no-op
   }

   /**
    * A fill character was parsed.
    * <p>
    * The default implementation does nothing.
    *
    * @param c
    *        the fill character
    */
   protected void visitFillChar(final char c)
   {
      // no-op
   }

   /**
    * Generate an error if no digits were found in a numeric format string.
    *
    * @param    fmt
    *           The incorrect format string.
    *
    * @throws   ErrorConditionException
    */
   private static void genNoDigitsError(final String fmt) throws ErrorConditionException
   {
      ErrorManager.recordOrThrowError(148,
               String.format("Numeric format %s provides for no digits", fmt));
   }
}