<?php
/*
 * This file is part of the JShrink package.
 *
 * (c) Robert Hafner <tedivm@tedivm.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

/**
 * JShrink
 *
 *
 * @package    JShrink
 * @author     Robert Hafner <tedivm@tedivm.com>
 */

namespace JShrink;

/**
 * Minifier
 *
 * Usage - Minifier::minify($js);
 * Usage - Minifier::minify($js, $options);
 * Usage - Minifier::minify($js, array('flaggedComments' => false));
 *
 * @package JShrink
 * @author Robert Hafner <tedivm@tedivm.com>
 * @license http://www.opensource.org/licenses/bsd-license.php  BSD License
 */
class Minifier {
  /**
   * The input javascript to be minified.
   *
   * @var string
   */
  protected $input;

  /**
   * The location of the character (in the input string) that is next to be
   * processed.
   *
   * @var int
   */
  protected $index = 0;

  /**
   * The first of the characters currently being looked at.
   *
   * @var string
   */
  protected $a = '';

  /**
   * The next character being looked at (after a);
   *
   * @var string
   */
  protected $b = '';

  /**
   * This character is only active when certain look ahead actions take place.
   *
   * @var string
   */
  protected $c;

  /**
   * Contains the options for the current minification process.
   *
   * @var array
   */
  protected $options;

  /**
   * Contains the default options for minification. This array is merged with
   * the one passed in by the user to create the request specific set of
   * options (stored in the $options attribute).
   *
   * @var array
   */
  protected static $defaultOptions = array('flaggedComments' => TRUE);

  /**
   * Contains lock ids which are used to replace certain code patterns and
   * prevent them from being minified
   *
   * @var array
   */
  protected $locks = array();

  /**
   * Takes a string containing javascript and removes unneeded characters in
   * order to shrink the code without altering it's functionality.
   *
   * @param  string $js The raw javascript to be minified
   * @param  array $options Various runtime options in an associative array
   * @throws \Exception
   * @return bool|string
   */
  public static function minify($js, $options = array()) {
    try {
      ob_start();

      $jshrink = new Minifier();
      $js = $jshrink->lock($js);
      $jshrink->minifyDirectToOutput($js, $options);

      // Sometimes there's a leading new line, so we trim that out here.
      $js = ltrim(ob_get_clean());
      $js = $jshrink->unlock($js);
      unset($jshrink);

      return $js;

    } catch (\Exception $e) {

      if (isset($jshrink)) {
        // Since the breakdownScript function probably wasn't finished
        // we clean it out before discarding it.
        $jshrink->clean();
        unset($jshrink);
      }

      // without this call things get weird, with partially outputted js.
      ob_end_clean();
      throw $e;
    }
  }

  /**
   * Processes a javascript string and outputs only the required characters,
   * stripping out all unneeded characters.
   *
   * @param string $js The raw javascript to be minified
   * @param array $options Various runtime options in an associative array
   */
  protected function minifyDirectToOutput($js, $options) {
    $this->initialize($js, $options);
    $this->loop();
    $this->clean();
  }

  /**
   *  Initializes internal variables, normalizes new lines,
   *
   * @param string $js The raw javascript to be minified
   * @param array $options Various runtime options in an associative array
   */
  protected function initialize($js, $options) {
    $this->options = array_merge(static::$defaultOptions, $options);
    $js = str_replace("\r\n", "\n", $js);
    $js = str_replace('/**/', '', $js);
    $this->input = str_replace("\r", "\n", $js);

    // We add a newline to the end of the script to make it easier to deal
    // with comments at the bottom of the script- this prevents the unclosed
    // comment error that can otherwise occur.
    $this->input .= PHP_EOL;

    // Populate "a" with a new line, "b" with the first character, before
    // entering the loop
    $this->a = "\n";
    $this->b = $this->getReal();
  }

  /**
   * The primary action occurs here. This function loops through the input string,
   * outputting anything that's relevant and discarding anything that is not.
   */
  protected function loop() {
    while ($this->a !== FALSE && !is_null($this->a) && $this->a !== '') {

      switch ($this->a) {
        // new lines
        case "\n":
          // if the next line is something that can't stand alone preserve the newline
          if (strpos('(-+{[@', $this->b) !== FALSE) {
            echo $this->a;
            $this->saveString();
            break;
          }

          // if B is a space we skip the rest of the switch block and go down to the
          // string/regex check below, resetting $this->b with getReal
          if ($this->b === ' ') {
            break;
          }

        // otherwise we treat the newline like a space

        case ' ':
          if (static::isAlphaNumeric($this->b)) {
            echo $this->a;
          }

          $this->saveString();
          break;

        default:
          switch ($this->b) {
            case "\n":
              if (strpos('}])+-"\'', $this->a) !== FALSE) {
                echo $this->a;
                $this->saveString();
                break;
              }
              else {
                if (static::isAlphaNumeric($this->a)) {
                  echo $this->a;
                  $this->saveString();
                }
              }
              break;

            case ' ':
              if (!static::isAlphaNumeric($this->a)) {
                break;
              }

            default:
              // check for some regex that breaks stuff
              if ($this->a === '/' && ($this->b === '\'' || $this->b === '"')) {
                $this->saveRegex();
                continue 3;
              }

              echo $this->a;
              $this->saveString();
              break;
          }
      }

      // do reg check of doom
      $this->b = $this->getReal();

      if (($this->b == '/' && strpos('(,=:[!&|?', $this->a) !== FALSE)) {
        $this->saveRegex();
      }
    }
  }

  /**
   * Resets attributes that do not need to be stored between requests so that
   * the next request is ready to go. Another reason for this is to make sure
   * the variables are cleared and are not taking up memory.
   */
  protected function clean() {
    unset($this->input);
    $this->index = 0;
    $this->a = $this->b = '';
    unset($this->c);
    unset($this->options);
  }

  /**
   * Returns the next string for processing based off of the current index.
   *
   * @return string
   */
  protected function getChar() {
    // Check to see if we had anything in the look ahead buffer and use that.
    if (isset($this->c)) {
      $char = $this->c;
      unset($this->c);

      // Otherwise we start pulling from the input.
    }
    else {
      $char = substr($this->input, $this->index, 1);

      // If the next character doesn't exist return false.
      if (isset($char) && $char === FALSE) {
        return FALSE;
      }

      // Otherwise increment the pointer and use this char.
      $this->index++;
    }

    // Normalize all whitespace except for the newline character into a
    // standard space.
    if ($char !== "\n" && ord($char) < 32) {
      return ' ';
    }

    return $char;
  }

  /**
   * This function gets the next "real" character. It is essentially a wrapper
   * around the getChar function that skips comments. This has significant
   * performance benefits as the skipping is done using native functions (ie,
   * c code) rather than in script php.
   *
   *
   * @return string            Next 'real' character to be processed.
   * @throws \RuntimeException
   */
  protected function getReal() {
    $startIndex = $this->index;
    $char = $this->getChar();

    // Check to see if we're potentially in a comment
    if ($char !== '/') {
      return $char;
    }

    $this->c = $this->getChar();

    if ($this->c === '/') {
      return $this->processOneLineComments($startIndex);

    }
    elseif ($this->c === '*') {
      return $this->processMultiLineComments($startIndex);
    }

    return $char;
  }

  /**
   * Removed one line comments, with the exception of some very specific types of
   * conditional comments.
   *
   * @param  int $startIndex The index point where "getReal" function started
   * @return string
   */
  protected function processOneLineComments($startIndex) {
    $thirdCommentString = substr($this->input, $this->index, 1);

    // kill rest of line
    $this->getNext("\n");

    if ($thirdCommentString == '@') {
      $endPoint = $this->index - $startIndex;
      unset($this->c);
      $char = "\n" . substr($this->input, $startIndex, $endPoint);
    }
    else {
      // first one is contents of $this->c
      $this->getChar();
      $char = $this->getChar();
    }

    return $char;
  }

  /**
   * Skips multiline comments where appropriate, and includes them where needed.
   * Conditional comments and "license" style blocks are preserved.
   *
   * @param  int $startIndex The index point where "getReal" function started
   * @return bool|string       False if there's no character
   * @throws \RuntimeException Unclosed comments will throw an error
   */
  protected function processMultiLineComments($startIndex) {
    $this->getChar(); // current C
    $thirdCommentString = $this->getChar();

    // kill everything up to the next */ if it's there
    if ($this->getNext('*/')) {

      $this->getChar(); // get *
      $this->getChar(); // get /
      $char = $this->getChar(); // get next real character

      // Now we reinsert conditional comments and YUI-style licensing comments
      if (($this->options['flaggedComments'] && $thirdCommentString === '!')
        || ($thirdCommentString === '@')
      ) {

        // If conditional comments or flagged comments are not the first thing in the script
        // we need to echo a and fill it with a space before moving on.
        if ($startIndex > 0) {
          echo $this->a;
          $this->a = " ";

          // If the comment started on a new line we let it stay on the new line
          if ($this->input[($startIndex - 1)] === "\n") {
            echo "\n";
          }
        }

        $endPoint = ($this->index - 1) - $startIndex;
        echo substr($this->input, $startIndex, $endPoint);

        return $char;
      }

    }
    else {
      $char = FALSE;
    }

    if ($char === FALSE) {
      throw new \RuntimeException('Unclosed multiline comment at position: ' . ($this->index - 2));
    }

    // if we're here c is part of the comment and therefore tossed
    if (isset($this->c)) {
      unset($this->c);
    }

    return $char;
  }

  /**
   * Pushes the index ahead to the next instance of the supplied string. If it
   * is found the first character of the string is returned and the index is set
   * to it's position.
   *
   * @param  string $string
   * @return string|false Returns the first character of the string or false.
   */
  protected function getNext($string) {
    // Find the next occurrence of "string" after the current position.
    $pos = strpos($this->input, $string, $this->index);

    // If it's not there return false.
    if ($pos === FALSE) {
      return FALSE;
    }

    // Adjust position of index to jump ahead to the asked for string
    $this->index = $pos;

    // Return the first character of that string.
    return substr($this->input, $this->index, 1);
  }

  /**
   * When a javascript string is detected this function crawls for the end of
   * it and saves the whole string.
   *
   * @throws \RuntimeException Unclosed strings will throw an error
   */
  protected function saveString() {
    $startpos = $this->index;

    // saveString is always called after a gets cleared, so we push b into
    // that spot.
    $this->a = $this->b;

    // If this isn't a string we don't need to do anything.
    if ($this->a !== "'" && $this->a !== '"') {
      return;
    }

    // String type is the quote used, " or '
    $stringType = $this->a;

    // Echo out that starting quote
    echo $this->a;

    // Loop until the string is done
    while (TRUE) {

      // Grab the very next character and load it into a
      $this->a = $this->getChar();

      switch ($this->a) {

        // If the string opener (single or double quote) is used
        // output it and break out of the while loop-
        // The string is finished!
        case $stringType:
          break 2;

        // New lines in strings without line delimiters are bad- actual
        // new lines will be represented by the string \n and not the actual
        // character, so those will be treated just fine using the switch
        // block below.
        case "\n":
          throw new \RuntimeException('Unclosed string at position: ' . $startpos);
          break;

        // Escaped characters get picked up here. If it's an escaped new line it's not really needed
        case '\\':

          // a is a slash. We want to keep it, and the next character,
          // unless it's a new line. New lines as actual strings will be
          // preserved, but escaped new lines should be reduced.
          $this->b = $this->getChar();

          // If b is a new line we discard a and b and restart the loop.
          if ($this->b === "\n") {
            break;
          }

          // echo out the escaped character and restart the loop.
          echo $this->a . $this->b;
          break;


        // Since we're not dealing with any special cases we simply
        // output the character and continue our loop.
        default:
          echo $this->a;
      }
    }
  }

  /**
   * When a regular expression is detected this function crawls for the end of
   * it and saves the whole regex.
   *
   * @throws \RuntimeException Unclosed regex will throw an error
   */
  protected function saveRegex() {
    echo $this->a . $this->b;

    while (($this->a = $this->getChar()) !== FALSE) {
      if ($this->a === '/') {
        break;
      }

      if ($this->a === '\\') {
        echo $this->a;
        $this->a = $this->getChar();
      }

      if ($this->a === "\n") {
        throw new \RuntimeException('Unclosed regex pattern at position: ' . $this->index);
      }

      echo $this->a;
    }
    $this->b = $this->getReal();
  }

  /**
   * Checks to see if a character is alphanumeric.
   *
   * @param  string $char Just one character
   * @return bool
   */
  protected static function isAlphaNumeric($char) {
    return preg_match('/^[\w\$\pL]$/', $char) === 1 || $char == '/';
  }

  /**
   * Replace patterns in the given string and store the replacement
   *
   * @param  string $js The string to lock
   * @return bool
   */
  protected function lock($js) {
    /* lock things like <code>"asd" + ++x;</code> */
    $lock = '"LOCK---' . crc32(time()) . '"';

    $matches = array();
    preg_match('/([+-])(\s+)([+-])/S', $js, $matches);
    if (empty($matches)) {
      return $js;
    }

    $this->locks[$lock] = $matches[2];

    $js = preg_replace('/([+-])\s+([+-])/S', "$1{$lock}$2", $js);
    /* -- */

    return $js;
  }

  /**
   * Replace "locks" with the original characters
   *
   * @param  string $js The string to unlock
   * @return bool
   */
  protected function unlock($js) {
    if (empty($this->locks)) {
      return $js;
    }

    foreach ($this->locks as $lock => $replacement) {
      $js = str_replace($lock, $replacement, $js);
    }

    return $js;
  }

}