<?php



namespace WPGMZA\Selector;



function trace($str)

{

	echo $str . "\r\n";

}



class ParseException extends \Exception

{

	public $css;

	

	public function __construct($message, $code = 0, Exception $previous = null) {

        \Exception::__construct($message, $code, $previous);

    }

}



class ConvertException extends \Exception

{

	public function __construct($message, $code = 0, Exception $previous = null) {

        \Exception::__construct($message, $code, $previous);

    }

}



class Token

{

	const OPEN_BRACKET							= '[';

	const CLOSE_BRACKET							= ']';

	const OPEN_PARENTHES						= '(';

	const CLOSE_PARENTHES						= ')';

	const CLASS_SHORTHAND						= '.';

	const ID_SHORTHAND							= '#';

	const SINGLE_QUOTE							= "'";

	const ANY_ELEMENT							= '*';

	const DOUBLE_QUOTE							= '"';

	const PSEUDO								= ':';

	const UNION_OPERATOR						= ',';

	const DESCENDANT_COMBINATOR					= ' ';

	const CHILD_COMBINATOR						= '>';

	const ADJACENT_SIBLING_COMBINATOR			= '+';

	const GENERAL_SIBLING_COMBINATOR			= '~';

	const ATTRIBUTE_EQUAL_TO					= '=';

	const ATTRIBUTE_WHITESPACE_LIST_CONTAINS	= '~=';

	const ATTRIBUTE_BEGINS_WITH					= '^=';

	const ATTRIBUTE_ENDS_WITH					= '$=';

	const ATTRIBUTE_CONTAINS_SUBSTRING			= '*=';

	const ATTRIBUTE_HYPHEN_LIST_BEGINS_WITH		= '|=';

	

	const IDENTIFIER							= -1;

	const STRING								= -2;

	const EXPRESSION							= -3;

	

	public $type = null;

	public $string;

	

	public function __construct($type, $string)

	{

		if(empty($string) && $string !== '0' || $string === '')

			throw new \Exception('Token string cannot be empty');

		

		$this->type = $type;

		$this->string = $string;

		

		//trace("Created token '$string' with type " . $this->getFriendlyType() . "\r\n");

	}

	

	public function isCombinator()

	{

		switch($this->type)

		{

			case Token::DESCENDANT_COMBINATOR:

			case Token::CHILD_COMBINATOR:

			case Token::ADJACENT_SIBLING_COMBINATOR:

			case Token::GENERAL_SIBLING_COMBINATOR:

				return true;

		}

		return false;

	}

	

	public function isAttributeOperator()

	{

		switch($this->type)

		{

			case Token::ATTRIBUTE_EQUAL_TO:

			case Token::ATTRIBUTE_WHITESPACE_LIST_CONTAINS:

			case Token::ATTRIBUTE_BEGINS_WITH:

			case Token::ATTRIBUTE_ENDS_WITH:

			case Token::ATTRIBUTE_CONTAINS_SUBSTRING:

			case Token::ATTRIBUTE_HYPHEN_LIST_BEGINS_WITH:

				return true;

				break;

		}

		return false;

	}

	

	public function getFriendlyType()

	{

		$ref = new \ReflectionClass(__CLASS__);

		$constants = $ref->getConstants();

		

		foreach($constants as $type => $string)

		{

			if($string == $this->type)

			{

				return $type;

			}

		}

			

		return "NULL";

	}

	

	public function __toString()

	{

		$friendly = $this->getFriendlyType();

		$spaces = 36 - strlen($friendly);

		return $friendly . str_repeat(" ", $spaces) . $this->string;

	}

}



class TokenStream

{

	protected $tokens;

	protected $cursor;

	

	public function __construct($arr)

	{

		$this->tokens = $arr;

		$this->cursor = 0;

	}

	

	public function peek($expectedType=null, $calledByRead=0)

	{

		//$backtrace = debug_backtrace();

		$token = (isset($this->tokens[$this->cursor]) ? $this->tokens[$this->cursor] : null);

		

		if($expectedType !== null)

		{

			if($token == null)

				throw new ParseException('Unexpected end');

			if($token->type != $expectedType)

				throw new ParseException('Unexpected ' . $token->getFriendlyType() . ' "' . $token->string . '", expecting "' . $expectedType . '"');

		}

		

		$action = ($calledByRead ? 'Read' : 'Peeked at');

		//trace($backtrace[1+$calledByRead]["class"] . '::' . $backtrace[1+$calledByRead]["function"] . "() [" . $backtrace[0+$calledByRead]["line"] . "]\t:- $action token $token");

		return $token;

	}

	

	public function read($expectedType=null)

	{

		$token = $this->peek($expectedType, 1);

		

		if(++$this->cursor >= count($this->tokens))

			$this->cursor = count($this->tokens);

		

		return $token;

	}

	

	public function eof()

	{

		return ($this->cursor >= count($this->tokens));

	}

}



class Tokenizer

{

	protected $tokens;

	protected $prevToken;

	

	protected function pushToken($type, $char)

	{

		return array_push($this->tokens, new Token($type, $char));

	}

	

	protected function pushCharToString($char)

	{

		//trace("Pushing '$char'\r\n");

		

		if(($curr = $this->getCurrToken()) && $curr->type == Token::STRING)

			$curr->string .= $char;

		else

			$this->pushToken(Token::STRING, $char);

	}



	protected function pushCharToIdentifier($char)

	{

		//trace("Pushing '$char'\r\n");

		

		if(($curr = $this->getCurrToken()) && $curr->type == Token::IDENTIFIER)

			$curr->string .= $char;

		else

			$this->pushToken(Token::IDENTIFIER, $char);

	}

	

	protected function pushCharToExpression($char)

	{

		//trace("Pushing '$char'\r\n");

		

		if(($curr = $this->getCurrToken()) && $curr->type == Token::EXPRESSION)

			$curr->string .= $char;

		else

			$this->pushToken(Token::EXPRESSION, $char);

	}

	

	protected function popToken()

	{

		$result = array_pop($this->tokens);

		//trace("Popped token " . $result . "\r\n");

		return $result;

	}

	

	protected function getCurrToken()

	{

		if(empty($this->tokens))

			return null;

		return $this->tokens[ count($this->tokens) - 1 ];

	}

	

	public function tokenize($str)

	{

		// Tokenize

		$str = preg_replace('/\s+/', ' ', trim($str));

		$length = strlen($str);

		

		$cursor = 0;

		$flags = (object)array(

			'brackets'	 		=> 0,

			'parentheses'		=> array(),

			'string'			=> false,

			'escaped'			=> false

		);



		$this->tokens = array();

		

		for($cursor = 0; $cursor < $length; $cursor++)

		{

			$curr = $this->getCurrToken();

			$char = substr($str, $cursor, 1);

			$next_two_chars = substr($str, $cursor, 2);

			

			//trace(preg_replace('/\r?\n/', '  ', $str) . "\r\n" . str_repeat(' ', $cursor) . "^ $cursor");

			//trace("Current token: " . $curr);

			

			if(!$flags->escaped)

			{

				switch($next_two_chars)

				{

					case Token::ATTRIBUTE_WHITESPACE_LIST_CONTAINS:

					case Token::ATTRIBUTE_BEGINS_WITH:

					case Token::ATTRIBUTE_ENDS_WITH:

					case Token::ATTRIBUTE_CONTAINS_SUBSTRING:

					case Token::ATTRIBUTE_HYPHEN_LIST_BEGINS_WITH:

						$this->pushToken($next_two_chars, $next_two_chars);

						$cursor++;

						continue 2;

				}

			}

			

			if($char == "\\")

			{

				if($flags->escaped)

					$this->pushCharToString($char);

			

				$flags->escaped = !$flags->escaped;

				//trace($flags->escaped ? "Escaped" : "Unescaped");

			}

			else if($flags->string)

			{

				//trace("Reading {$flags->string} quoted string");

				switch($char)

				{

					case Token::SINGLE_QUOTE:

					case Token::DOUBLE_QUOTE:

						if($flags->escaped)

						{

							$this->pushCharToString($char);

							break;

						}

					

						$double = ($char == '"');

						

						if(($double && $flags->string == 'double') || (!$double && $flags->string == 'single'))

						{

							$flags->string = false;

							$this->pushToken(

								$double ? Token::DOUBLE_QUOTE : Token::SINGLE_QUOTE,

								$char

							);

							//trace("Exited {$flags->string} quoted string");

						}

						else

							$this->pushCharToString($char);

						break;

						

					default:

						$this->pushCharToString($char);

						break;

				}

				

				if($flags->escaped)

				{

					$flags->escaped = false;

					//trace("Unescaped at end of reading string");

				}

			}

			else if($flags->escaped)

			{

				$this->pushCharToIdentifier($char);

				$flags->escaped = false;

				//trace("Unescaped in else-if clause");

			}

			else

			{

				switch($char)

				{

					case Token::SINGLE_QUOTE:

					case Token::DOUBLE_QUOTE:

						if($flags->escaped)

						{

							$this->pushCharToIdentifier($char);

							break;

						}

					

						$double = ($char == '"');

					

						$flags->string = ($double ? 'double' : 'single');

						$this->pushToken(

							$double ? Token::DOUBLE_QUOTE : Token::SINGLE_QUOTE,

							$char

						);

						

						//trace("Entered {$flags->string} quoted string");

						break;

					

					case Token::OPEN_BRACKET:

						$var = ($char == Token::OPEN_BRACKET ? 'brackets' : 'parentheses');

					

						$flags->brackets++;

					

						if($flags->brackets > 1)

							throw new ParseException('Unexpected ' . $char);

						

						$this->pushToken($char, $char);

						//trace("Entered brackets");

						

						break;

					

					case Token::CLOSE_BRACKET:

						$flags->brackets--;

					

						if($flags->brackets < 0)

							throw new ParseException('Unexpected ' . $char);

						

						$this->pushToken($char, $char);

						//trace("Exited brackets");

					

						break;

					

					case Token::OPEN_PARENTHES:

						array_push($flags->parentheses, $curr->string);

						$this->pushToken($char, $char);

						//trace("Entered brackets");

						break;



					case Token::CLOSE_PARENTHES:

						array_pop($flags->parentheses);

						$this->pushToken($char, $char);

						//trace("Exited brackets");

						break;

						

					case Token::UNION_OPERATOR:						

					case Token::CLASS_SHORTHAND:

					case Token::ID_SHORTHAND:

					case Token::ANY_ELEMENT:

					case Token::PSEUDO:

					case Token::UNION_OPERATOR:

					case Token::ATTRIBUTE_EQUAL_TO:

						if($flags->escaped)

							break;

						

						$this->pushToken($char, $char);

						

						break;

						

					case Token::ADJACENT_SIBLING_COMBINATOR:

						if(count($flags->parentheses) > 0)

						{

							$this->pushCharToExpression($char);

							break;

						}

					case Token::CHILD_COMBINATOR:

					case Token::GENERAL_SIBLING_COMBINATOR:

						$curr = $this->getCurrToken();

						if($curr && $curr->type == Token::DESCENDANT_COMBINATOR)

							$this->popToken();

						

						$this->pushToken($char, $char);

						

						break;

						

					case " ":

					case "\r":

					case "\n":

					case "\t":

						$curr = $this->getCurrToken();

						

						if($flags->brackets > 0 || count($flags->parentheses) > 0)

						{

							break;

						}

						if($curr)

						{

							if($curr->type == Token::UNION_OPERATOR)

								break;

							if($curr->isCombinator())

								break;

						}

						else

							break;

						

						$this->pushToken(Token::DESCENDANT_COMBINATOR, $char);

						

						break;

						

					default:

						if(count($flags->parentheses) > 0 && !preg_match('/not/i', $flags->parentheses[count($flags->parentheses) - 1]))

							$this->pushCharToExpression($char);

						else

							$this->pushCharToIdentifier($char);

						break;

				}

			}

			

			//trace("");

		}

		

		return $this->tokens;

	}

}



class PseudoSelector

{

	public $name;

	public $expression;

	public $selector;

	

	public function parse($stream, $selector)

	{

		$first = $stream->read(Token::PSEUDO);

		$token = $stream->read(Token::IDENTIFIER);

		

		$this->name = strtolower($token->string);

		switch($this->name)

		{

			case 'nth-child':

			case 'nth-last-child':

			case 'nth-of-type':

			case 'nth-last-of-type':

			case 'not':

				break;

			

			case 'first-child':

			case 'last-child':

			case 'first-of-type':

			case 'last-of-type':

			case 'only-child':

			case 'only-of-type':

			case 'empty':

			case 'enabled':

			case 'disabled':

			case 'checked':

				return;

				break;

			

			case 'link':

			case 'visited':

			case 'active':

			case 'hover':

			case 'focus':

			case 'target':

				throw new ParseException('Pseudo selector not supported server side');

				break;

			

			case 'root':

				// See https://en.wikibooks.org/wiki/XPath/CSS_Equivalents for root. Will need to pop descendant:: off stack

			

			case 'lang':

				throw new ParseException('Pseudo selector not yet implemented');

				break;

				

			default:

				throw new ParseException('Unknown pseudo selector');

				break;

		}

		

		$stream->read(Token::OPEN_PARENTHES);

		if($this->name == 'not')

		{

			if($selector->parent)

			{

				foreach($selector->parent->selector->pseudos as $parent_pseudo)

				{

					if($parent_pseudo->name == 'not')

						throw new ParseException(':not pseudo selector cannot be nested');

				}

			}

			

			$this->selector = new Selector($stream);

			$this->selector->parent = $this;

			$this->selector->parse($stream, $this);

		}

		else

			$this->expression = $stream->read(Token::EXPRESSION);

		$stream->read(Token::CLOSE_PARENTHES);

	}

}



class AttributeSelector

{

	public $name;

	public $operator;

	public $value;

	

	public function parse($stream)

	{

		// Expect [ first

		$stream->read(Token::OPEN_BRACKET);

		

		// Read name

		$token = $stream->read(Token::IDENTIFIER);

		$this->name = $token->string;

		

		// Read operator

		$token = $stream->read();

		if(!$token)

			throw new ParseException('Unexpected end in attribute');

		if($token->type == Token::CLOSE_BRACKET)

			return;	// has attribute

		if(!$token->isAttributeOperator())

			throw new ParseException('Expected either close bracket or attribute operator');

		$this->operator = $token->string;

		

		// Read string value

		$token = $stream->read();

		if(!$token)

			throw new ParseException('Unexpected end in attribute');

		if($token->type != Token::SINGLE_QUOTE && $token->type != Token::DOUBLE_QUOTE)

			throw new ParseException('Expected quote to open string after attribute operator');

		$openQuoteType = $token->type;



		$after = $stream->peek();

		if($after->type == $openQuoteType)

		{

			// Empty string

			$this->value = '';

		}

		else

		{

			// Read value

			$token = $stream->read(Token::STRING);

			$this->value = $token->string;

		}

		

		$token = $stream->read();

		if(!$token)

			throw new ParseException('Unexpected end in attribute');

		if($token->type != Token::SINGLE_QUOTE && $token->type != Token::DOUBLE_QUOTE)

			throw new ParseException('Expected quote to terminate string after attribute operator');

		

		// Expect ]

		$stream->read(Token::CLOSE_BRACKET);

	}

}



class Selector

{

	public $element = '*';

	public $id;

	public $classes;

	public $attributes;

	public $pseudos;

	

	public $parent;

	

	public function parse($stream, $not=null)

	{

		$first = $stream->peek();

		

		switch($first->type)

		{

			case Token::ANY_ELEMENT:

			case Token::IDENTIFIER:

				$this->element = $stream->read()->string;

				break;

				

			case Token::OPEN_BRACKET:

			case Token::PSEUDO:

			case Token::ID_SHORTHAND:

			case Token::CLASS_SHORTHAND:

				break;

				

			default:

				throw new ParseException("Unexpected token '{$token->string}'");

				break;

		}

		

		while($token = $stream->peek())

		{

			if($token->isCombinator() || $token->type == Token::UNION_OPERATOR)

				return;

			

			switch($token->type)

			{

				case Token::ID_SHORTHAND:

					if($this->id != null)

						throw new ParseExcepton('Selector can only have one ID');

					$stream->read(Token::ID_SHORTHAND);

					$this->id = $stream->read(Token::IDENTIFIER)->string;

					//trace("Read ID as {$this->id}");

					continue 2;

					break;

					

				case Token::CLASS_SHORTHAND:

					if(!$this->classes)

						$this->classes = array();

					$stream->read(Token::CLASS_SHORTHAND);

					array_push($this->classes, $stream->read(Token::IDENTIFIER)->string);

					continue 2;

					break;

				

				case Token::OPEN_BRACKET:

					if(!$this->attributes)

						$this->attributes = array();

				

					$attributeSelector = new AttributeSelector();

					array_push($this->attributes, $attributeSelector);

					$attributeSelector->parse($stream);

					continue 2;

					break;

				

				case Token::PSEUDO:

					if(!$this->pseudos)

						$this->pseudos = array();

					

					$pseudoSelector = new PseudoSelector();

					array_push($this->pseudos, $pseudoSelector);

					$pseudoSelector->parse($stream, $this);

					continue 2;

					break;

					

				case Token::CLOSE_PARENTHES:

					if($not != null)

						return;

					

				default:

					throw new ParseException("Unexpected token '{$token->string}'");

					break;

			}

			

			$stream->read();

		}

		

		

	}

}



class Parser

{

	protected $tokenizer;

	protected $elements;

	

	public function __construct()

	{

		$this->tokenizer = new Tokenizer();

	}

	

	public function parse($selector)

	{

		$tokens = $this->tokenizer->tokenize($selector);

		$stream = new TokenStream($tokens);

		$this->elements = array();

		

		while(!$stream->eof())

		{

			$token = $stream->peek();

			if($token->isCombinator() || $token->type == Token::UNION_OPERATOR)

			{

				if(empty($this->elements))

					throw new ParseException('Expected selector before combinator or union operator');

				array_push($this->elements, $stream->read());

			}

	

			$selector = new Selector();

			$selector->parse($stream);

			array_push($this->elements, $selector);

			

			//trace(print_r($selector, true));

		}

		

		return $this->elements;

	}



	

}



class XPathConverter

{

	protected $parser;

	protected $xpath;

	

	public function __construct()

	{

		$this->parser = new Parser();

	}

	

	protected function convertAttribute($attr)

	{

		$name = $attr->name;

		$value = addslashes($attr->value);

		

		switch($attr->operator)

		{

			case null:

				$inner = "@{$name}";

				break;

				

			case Token::ATTRIBUTE_EQUAL_TO:

				$inner = "@$name=\"$value\"";

				break;

			

			case Token::ATTRIBUTE_WHITESPACE_LIST_CONTAINS:

				$inner = "conatins(concat(\" \", normalize-space(@$name), \" \"), \" $value \")";

				break;

			

			case Token::ATTRIBUTE_BEGINS_WITH:

				$inner = "starts-with(@$name, \"$value\")";

				break;

			

			case Token::ATTRIBUTE_ENDS_WITH:

				$inner = "substring(@$name, string-length(@$name) - string-length(\"$value\") + 1) = \"$value\"";

				break;

			

			case Token::ATTRIBUTE_CONTAINS_SUBSTRING:

				$inner = "contains(@$name, \"$value\")";

				break;

			

			case Token::ATTRIBUTE_HYPHEN_LIST_BEGINS_WITH:

				$inner = "@$name=\"@$value\" | starts-with(@$name, \"@$value-\")";

				break;

				

			default:

				throw new ConvertException("Don't know how to convert operator {$attr->operator}");

				break;

		}

		array_push($this->xpath, "[$inner]");

	}

	

	protected function convertPseudo($pseudo)

	{

		$name = $pseudo->name;

		

		// TODO: The "of type" selectors should change the parent selector element to *, and should use that type in their xpath

		// TODO: Test with a live domdocument in here (or smartdocument even)

		

		switch($name)

		{

			case 'nth-child':

			case 'nth-last-child':

			case 'nth-of-type':

			case 'nth-last-of-type':

				// TODO: Support formulas / expressions

				throw new ConvertException('Not yet implemented');

				break;

			

			case 'first-child':

			case 'first-of-type':

				$inner = '1';

				break;

			

			case 'last-child':

			case 'last-of-type':

				$inner = 'last()';

				break;

			

			case 'only-child':

			case 'only-of-type':

				$inner = 'count(*)=1';	// TODO: might need to swap * with node name

				break;

			

			case 'empty':

				$inner = 'count(./*)=0 and string-length(text())=0';

				break;

				

			case 'enabled':

				$inner = "not(@disabled)";

				break;

			

			case 'disabled':

			case 'checked':

				$inner = "@$name";

				break;

				

			case 'not':

				throw new ConvertException('Not yet implemented');

				break;

			

			default:

				throw new ConvertException("Don't know how to convert pseudo selector {$pseudo->name}");

				break;

		}

		

		array_push($this->xpath, "[$inner]");

	}

	

	protected function convertSelector($selector)

	{

		//trace("Converting selector " . print_r($selector, true));

		

		$prev = null;

		if(!empty($this->xpath))

			$prev = &$this->xpath[ count($this->xpath) - 1 ];

		

		if($prev == 'following-sibling::*[1]')

			$prev = preg_replace('/\*/', $selector->element, $prev);

		else

			array_push($this->xpath, $selector->element);

		

		if($selector->id != null)

			array_push($this->xpath, '[@id="' . $selector->id . '"]');

		

		if($selector->classes)

			foreach($selector->classes as $class)

			{

				array_push($this->xpath,

					'[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]'

				);

			}

			

		if($selector->attributes)

			foreach($selector->attributes as $attr)

				$this->convertAttribute($attr);

				

		if($selector->pseudos)

			foreach($selector->pseudos as $pseudo)

				$this->convertPseudo($pseudo);

	}

	

	protected function convertElement($element)

	{

		//trace("Converting element " . print_r($element, true));

		

		$prev = null;

		if(!empty($this->xpath))

			$prev = $this->xpath[ count($this->xpath) - 1 ];

		

		if($element instanceof Token)

		{

			if($prev != "\r\n|\r\n" && $element->type != Token::UNION_OPERATOR)

				array_push($this->xpath, '/');

			

			switch($element->type)

			{

				case Token::UNION_OPERATOR:

					array_push($this->xpath, "\r\n|\r\n");

					

				case Token::DESCENDANT_COMBINATOR:

					array_push($this->xpath, 'descendant::');

					return;

					

				case Token::CHILD_COMBINATOR:

					array_push($this->xpath, 'child::');

					return;

					

				case Token::GENERAL_SIBLING_COMBINATOR:

					array_push($this->xpath, 'following-sibling::');

					return;

					

				case Token::ADJACENT_SIBLING_COMBINATOR:

					array_push($this->xpath, 'following-sibling::*[1]');

					return;

					

				default:

					throw new ConvertException('Unexpected token');

					break;

			}

		}

		else if($element instanceof Selector)

			$this->convertSelector($element);

		else

			throw new ConvertException('Unexpected element');

	}

	

	public function convert($str)

	{

		//trace("=== Parsing $str ===\r\n");

		

		$this->xpath = array('descendant::');

		$elements = $this->parser->parse($str);

		

		//trace("=== Converting $str ===\r\n");

		foreach($elements as $el)

			$this->convertElement($el);

		

		return implode('', $this->xpath);

	}

}



?>