<?php
/**
 * WordPress Coding Standard.
 *
 * @package WPCS\WordPressCodingStandards
 * @link    https://github.com/WordPress/WordPress-Coding-Standards
 * @license https://opensource.org/licenses/MIT MIT
 */

namespace WordPressCS\WordPress\Sniffs\WhiteSpace;

use WordPressCS\WordPress\Sniff;
use WordPressCS\WordPress\PHPCSHelper;
use PHP_CodeSniffer\Util\Tokens;

/**
 * Warn on line indentation ending with spaces for precision alignment.
 *
 * WP demands tabs for indentation. In rare cases, spaces for precision alignment can be
 * intentional and acceptable, but more often than not, this is a typo.
 *
 * The `Generic.WhiteSpace.DisallowSpaceIndent` sniff already checks for space indentation
 * and auto-fixes to tabs.
 *
 * This sniff only checks for precision alignments which can not be corrected by the
 * `Generic.WhiteSpace.DisallowSpaceIndent` sniff.
 *
 * As this may be intentional, this sniff explicitly does *NOT* contain a fixer.
 *
 * @link    https://make.wordpress.org/core/handbook/best-practices/coding-standards/php/#indentation
 *
 * @package WPCS\WordPressCodingStandards
 *
 * @since   0.14.0
 */
class PrecisionAlignmentSniff extends Sniff {

	/**
	 * A list of tokenizers this sniff supports.
	 *
	 * @var array
	 */
	public $supportedTokenizers = array(
		'PHP',
		'JS',
		'CSS',
	);

	/**
	 * Allow for providing a list of tokens for which (preceding) precision alignment should be ignored.
	 *
	 * <rule ref="WordPress.WhiteSpace.PrecisionAlignment">
	 *    <properties>
	 *        <property name="ignoreAlignmentTokens" type="array">
	 *            <element value="T_COMMENT"/>
	 *            <element value="T_INLINE_HTML"/>
	 *        </property>
	 *    </properties>
	 * </rule>
	 *
	 * @var array
	 */
	public $ignoreAlignmentTokens = array();

	/**
	 * The --tab-width CLI value that is being used.
	 *
	 * @var int
	 */
	private $tab_width;

	/**
	 * Returns an array of tokens this test wants to listen for.
	 *
	 * @return array
	 */
	public function register() {
		return array(
			\T_OPEN_TAG,
			\T_OPEN_TAG_WITH_ECHO,
		);
	}

	/**
	 * Processes this test, when one of its tokens is encountered.
	 *
	 * @param int $stackPtr The position of the current token in the stack.
	 *
	 * @return int Integer stack pointer to skip the rest of the file.
	 */
	public function process_token( $stackPtr ) {
		if ( ! isset( $this->tab_width ) ) {
			$this->tab_width = PHPCSHelper::get_tab_width( $this->phpcsFile );
		}

		// Handle any custom ignore tokens received from a ruleset.
		$ignoreAlignmentTokens = $this->merge_custom_array( $this->ignoreAlignmentTokens );

		$check_tokens  = array(
			\T_WHITESPACE             => true,
			\T_INLINE_HTML            => true,
			\T_DOC_COMMENT_WHITESPACE => true,
			\T_COMMENT                => true,
		);
		$check_tokens += Tokens::$phpcsCommentTokens;

		for ( $i = 0; $i < $this->phpcsFile->numTokens; $i++ ) {

			if ( 1 !== $this->tokens[ $i ]['column'] ) {
				continue;
			} elseif ( isset( $check_tokens[ $this->tokens[ $i ]['code'] ] ) === false
				|| ( isset( $this->tokens[ ( $i + 1 ) ] )
					&& \T_WHITESPACE === $this->tokens[ ( $i + 1 ) ]['code'] )
				|| $this->tokens[ $i ]['content'] === $this->phpcsFile->eolChar
				|| isset( $ignoreAlignmentTokens[ $this->tokens[ $i ]['type'] ] )
				|| ( isset( $this->tokens[ ( $i + 1 ) ] )
					&& isset( $ignoreAlignmentTokens[ $this->tokens[ ( $i + 1 ) ]['type'] ] ) )
			) {
				continue;
			}

			$spaces = 0;
			switch ( $this->tokens[ $i ]['type'] ) {
				case 'T_WHITESPACE':
					$spaces = ( $this->tokens[ $i ]['length'] % $this->tab_width );
					break;

				case 'T_DOC_COMMENT_WHITESPACE':
					$length = $this->tokens[ $i ]['length'];
					$spaces = ( $length % $this->tab_width );

					if ( isset( $this->tokens[ ( $i + 1 ) ] )
						&& ( \T_DOC_COMMENT_STAR === $this->tokens[ ( $i + 1 ) ]['code']
							|| \T_DOC_COMMENT_CLOSE_TAG === $this->tokens[ ( $i + 1 ) ]['code'] )
						&& 0 !== $spaces
					) {
						// One alignment space expected before the *.
						--$spaces;
					}
					break;

				case 'T_COMMENT':
				case 'T_PHPCS_ENABLE':
				case 'T_PHPCS_DISABLE':
				case 'T_PHPCS_SET':
				case 'T_PHPCS_IGNORE':
				case 'T_PHPCS_IGNORE_FILE':
					/*
					 * Indentation whitespace for subsequent lines of multi-line comments
					 * are tokenized as part of the comment.
					 */
					$comment    = ltrim( $this->tokens[ $i ]['content'] );
					$whitespace = str_replace( $comment, '', $this->tokens[ $i ]['content'] );
					$length     = \strlen( $whitespace );
					$spaces     = ( $length % $this->tab_width );

					if ( isset( $comment[0] ) && '*' === $comment[0] && 0 !== $spaces ) {
						--$spaces;
					}
					break;

				case 'T_INLINE_HTML':
					if ( $this->tokens[ $i ]['content'] === $this->phpcsFile->eolChar ) {
						$spaces = 0;
					} else {
						/*
						 * Indentation whitespace for inline HTML is part of the T_INLINE_HTML token.
						 */
						$content    = ltrim( $this->tokens[ $i ]['content'] );
						$whitespace = str_replace( $content, '', $this->tokens[ $i ]['content'] );
						$spaces     = ( \strlen( $whitespace ) % $this->tab_width );
					}

					/*
					 * Prevent triggering on multi-line /*-style inline javascript comments.
					 * This may cause false negatives as there is no check for being in a
					 * <script> tag, but that will be rare.
					 */
					if ( isset( $content[0] ) && '*' === $content[0] && 0 !== $spaces ) {
						--$spaces;
					}
					break;
			}

			if ( $spaces > 0 && ! $this->has_whitelist_comment( 'precision alignment', $i ) ) {
				$this->phpcsFile->addWarning(
					'Found precision alignment of %s spaces.',
					$i,
					'Found',
					array( $spaces )
				);
			}
		}

		// Ignore the rest of the file.
		return ( $this->phpcsFile->numTokens + 1 );
	}

}