NumberFormatter.java

/*
** Module   : NumberFormatter.java
** Abstract : Format Progress numbers.
**
** Copyright (c) 2021-2022, Golden Code Development Corporation.
**
** -#- -I- --Number-- ---------------------------------Description---------------------------------
** 001 VVT 20210426 Created initial version.
**     VVT 20210624 Javadoc fixed
**     TJD 20220504 Java 11 compatibility minor changes
*/
/*
** 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.math.*;

import com.goldencode.p2j.ui.client.format.*;

/**
 * Format Progress numbers.
 * 
 */
public class NumberFormatter
extends NumberFormatParser
{
   /** Collector for the output formatted value */
   private StringBuilder sb;

   /** The value being formatted */
   private final BigDecimal value;

   /** {@code true} if we are formatting a negative number. */
   private final boolean isNegative;

   /** The left (integer) part of the formatted number */
   private final String leftDigits;

   /** The right (fraction) part of the formatted number */
   private final String rightDigits;

   /** Set to {@code true} by if any of {@link #visitLeftDigits(int, int)} or
    * {@link #visitRightDigits(int, int, int)} callbacks was called */
   private boolean digitsVisited;

   /** Set to {@code true} by {@link #visitSign(int, int)} digits callback */
   private boolean signVisited;

   /** The format string, used for error reporting from callbacks */
   private final String format;

   /** Any error related to a value being formatted occurred before the format parse is complete.
    * This value is used to defer processing of value-related error until format string is
    * completely parsed, and all format-related errors processed. */
   private Runnable deferredError = null;

   /**
    * The constructor.
    * 
    * @param value
    *        the value to format
    * @param fmt
    *        the format string
    */
   public NumberFormatter(final BigDecimal value, final String fmt)
   {
      this.value = value;
      final String full = value.abs().toString();
      final int pos = full.indexOf('.');
      if (pos < 0)
      {
         // no decimal point
         leftDigits = full;
         rightDigits = "";
      }
      else
      {
         // standard case
         leftDigits = full.substring(0, pos);
         rightDigits = full.substring(pos + 1);
      }

      this.isNegative = value.signum() < 0;
      this.format = fmt;
   }

   /**
    * Do the formatting.
    * 
    * @return the formatted number
    */
   public final String format()
   {
      sb = new StringBuilder();
      parse(format);
      final String result = sb.toString();

      if (isNegative && !signVisited)
      {
         DisplayFormat.genValueCannotBeDisplayedError(value.toString(), format);
         // FIXME: generate string of question marks?
         return "";
      }

      if (deferredError != null)
      {
         deferredError.run();
      }

      return result;
   }

   /**
    * A fill character was parsed.
    * 
    * @param c
    *        the character
    */
   @Override
   protected void visitFillChar(final char c)
   {
      if (deferredError != null)
      {
         return;
      }

      sb.append(c);
   }

   /**
    * A sign spec was parsed.
    * 
    * Sign specs are: '+', '-', '(', ')', DR, CR and DB.
    * 
    * @param startIdx
    *        the section start index in format string, zero-based, inclusive
    * @param endIdx
    *        the section end index in format string, zero-based, exclusive
    */
   @Override
   protected void visitSign(final int startIdx, final int endIdx)
   {
      if (deferredError != null)
      {
         return;
      }

      signVisited = true;
      switch (endIdx - startIdx)
      {
         case 1:
            // single format character
            final char in;
            final char c = format.charAt(startIdx);
            switch (c)
            {
               case '+':
                  in = isNegative ? '-' : '+';
                  break;

               case '-':
                  if (isNegative)
                  {
                     in = '-';
                     break;
                  }

                  if (!digitsVisited)
                  {
                     // If we are on left, insert spaces to the beginning of the output.
                     // Test the next format character: if 'z'
                     if (endIdx == format.length())
                     {
                        // Id we are at the end of the format string, then format is invalid:
                        // no digits. Just return and let the format parser handle this error.
                        return;
                     }

                     final char nextChar = format.charAt(endIdx);
                     if (nextChar == '>')
                     {
                        sb.insert(0, ' ');
                        return;
                     }
                  }

                  in = ' ';
                  break;

               case '(':
               case ')':
                  in = isNegative ? c : ' ';
                  break;

               default:
                  // Should never happen
                  throw new ErrorConditionException(-1, "Invalid sign character");
            }

            sb.append(in);
            break;

         case 2:
            // two-character spec: DR, CR, DB
            sb.append(isNegative ? format.substring(startIdx, endIdx) : "  ");
            break;

         default:
            // Should never happen
            throw new ErrorConditionException(-1, "Invalid sign specification");
      }
   }

   /**
    * Non-empty left digits section was parsed.
    *  
    * @param startIdx
    *        the section start index in format string, zero-based, inclusive
    * @param endIdx
    *        the section end index in format string, zero-based, exclusive
    */
   @Override
   protected void visitLeftDigits(final int startIdx, final int endIdx)
   {
      digitsVisited = true;
      int insertionPoint = sb.length();

      int formatIdx = endIdx - 1;
      int valueIdx = leftDigits.length() - 1;
      for (;; formatIdx--)
      {
         final boolean inLeadingZeroes = valueIdx < 0;
         if (formatIdx < startIdx && inLeadingZeroes)
         {
            return;
         }

         if (formatIdx < 0)
         {
            setDeferredError(leftDigits);
            return;
         }

         char formatChar = format.charAt(formatIdx);

         if (!inLeadingZeroes)
         {
            if (formatChar == ',')
            {
               sb.insert(insertionPoint, ',');
               continue;
            }

            if (formatIdx < startIdx)
            {
               if (formatChar == '+')
               {
                  insertionPoint--;
                  sb.deleteCharAt(insertionPoint);
               }
               else
               {
                  setDeferredError(leftDigits);
                  return;
               }
            }

            sb.insert(insertionPoint, leftDigits.charAt(valueIdx--));
            continue;
         }

         // If we are here, we are in leading zeroes

         // If a comma is preceded by 'z' or '>', it acts as the
         // preceding character, so just replace comma to the preceding character.
         if (formatChar == ',' && formatIdx > 0)
         {
            final char preceding = format.charAt(formatIdx - 1);
            switch (preceding)
            {
               case '>':
               case 'z':
               case 'Z':
               case '*':
                  formatChar = preceding;
                  break;

               default:
            }
         }

         if (formatChar == ',')
         {
            sb.insert(insertionPoint, ',');
            continue;
         }

         final char insertChar;
         switch (formatChar)
         {
            case '9':
               insertChar = '0';
               break;

            case '*':
               insertChar = '*';
               break;

            case 'z':
            case 'Z':
               insertChar = ' ';
               break;

            case '>':
               // We are inserting spaces to the beginning of the output.
               sb.insert(0, ' ');
               continue;

            case '(':
               insertChar = isNegative ? '(' : ' ';
               break;

            default:
               // Should never happen
               throw new ErrorConditionException(-1, "Invalid digit");
         }

         sb.insert(insertionPoint, insertChar);
      }
   }

   /**
    * Right digits section was parsed (may be empty).
    * 
    * @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
    */
   @Override
   protected void visitRightDigits(final int startIdx, final int endIdx, final int separators)
   {
      if (deferredError != null)
      {
         return;
      }
      
      digitsVisited = true;
      sb.append('.');
      if (startIdx == endIdx)
      {
         // Special case, check it here to prevent endless loop below
         return;
      }

      // Test if all fraction digits will fit, otherwise round the value

      // The number of outstanding digits
      final int unfitDigits = rightDigits.length() - (endIdx - startIdx - separators);
      final String src;
      if (unfitDigits > 0)
      {
         // If we are here, then some digits do not fit, do rounding and repeat
         final BigDecimal rounded = value.setScale(value.scale() - unfitDigits,
                  RoundingMode.HALF_UP);

         final String roundedString = rounded.toString();
         final int dotIdx = roundedString.indexOf('.');
         assert dotIdx >= 0;
         src = roundedString.substring(dotIdx + 1);
      }
      else
      {
         src = rightDigits;
      }

      final int srcLength = src.length();
      int digitsIdx = 0;

      for (int fmtIdx = startIdx; fmtIdx < endIdx; fmtIdx++)
      {
         final char fmtChar = format.charAt(fmtIdx);
         if (fmtChar == ',')
         {
            sb.append(',');
            continue;
         }

         if (digitsIdx < srcLength)
         {
            final int nextChar = src.charAt(digitsIdx++);
            sb.append((char) nextChar);
            continue;
         }

         switch (fmtChar)
         {
            case '9':
               sb.append('0');
               break;

            case '<':
               break;

            default:
               // Must never be here
               throw new ErrorConditionException(-1, "");
         }
      }
   }

   /**
    * Set the deferred error action.
    * <p>
    * Implementation should call this method only once when the first value-related
    * error is detected. 
    * 
    * @param reportedValue
    *        the value as string to use in the error message
    */
   private final void setDeferredError(final String reportedValue)
   {
      // Implementation should assert we never set it twice
      assert deferredError == null;
      
      deferredError = () -> DisplayFormat.genValueCannotBeDisplayedError(reportedValue, format);
   }
}