mirror of
https://github.com/smogon/pokemon-showdown-client.git
synced 2026-03-22 01:55:56 -05:00
958 lines
27 KiB
PHP
958 lines
27 KiB
PHP
<?php
|
|
/**
|
|
* @file
|
|
* @license https://opensource.org/licenses/Apache-2.0 Apache-2.0
|
|
*/
|
|
|
|
namespace Wikimedia\CSS\Parser;
|
|
|
|
use Wikimedia\CSS\Objects\Token;
|
|
|
|
/**
|
|
* Parse CSS into tokens
|
|
*
|
|
* This implements the tokenizer from the CSS Syntax Module Level 3 candidate recommendation.
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/
|
|
*/
|
|
class DataSourceTokenizer implements Tokenizer {
|
|
|
|
/** @var DataSource */
|
|
protected $source;
|
|
|
|
/** @var int position in the input */
|
|
protected $line = 1, $pos = 0;
|
|
|
|
/** @var string|null|object The most recently consumed character */
|
|
protected $currentCharacter = null;
|
|
|
|
/** @var string|null The next character to be consumed */
|
|
protected $nextCharacter = null;
|
|
|
|
/** @var array Parse errors. Each error is [ string $tag, int $line, int $pos ] */
|
|
protected $parseErrors = [];
|
|
|
|
/**
|
|
* @param DataSource $source
|
|
* @param array $options Configuration options.
|
|
* (none currently defined)
|
|
*/
|
|
public function __construct( DataSource $source, array $options = [] ) {
|
|
$this->source = $source;
|
|
}
|
|
|
|
/**
|
|
* Read a character from the data source
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#input-preprocessing
|
|
* @return string One UTF-8 character, or empty string on EOF
|
|
*/
|
|
protected function nextChar() {
|
|
$char = $this->source->readCharacter();
|
|
|
|
// Perform transformations per the spec
|
|
|
|
// Any U+0000 becomes U+FFFD
|
|
if ( $char === "\0" ) {
|
|
return \UtfNormal\Constants::UTF8_REPLACEMENT;
|
|
}
|
|
|
|
// Any U+000D, U+000C, or pair of U+000D + U+000A becomes U+000A
|
|
if ( $char === "\f" ) { // U+000C
|
|
return "\n";
|
|
}
|
|
|
|
if ( $char === "\r" ) { // Either U+000D + U+000A or a lone U+000D
|
|
$char2 = $this->source->readCharacter();
|
|
if ( $char2 !== "\n" ) {
|
|
$this->source->putBackCharacter( $char2 );
|
|
}
|
|
return "\n";
|
|
}
|
|
|
|
return $char;
|
|
}
|
|
|
|
/**
|
|
* Update the current and next character fields
|
|
*/
|
|
protected function consumeCharacter() {
|
|
if ( $this->currentCharacter === "\n" ) {
|
|
$this->line++;
|
|
$this->pos = 1;
|
|
} elseif ( $this->currentCharacter !== DataSource::EOF ) {
|
|
$this->pos++;
|
|
}
|
|
|
|
$this->currentCharacter = $this->nextChar();
|
|
$this->nextCharacter = $this->nextChar();
|
|
$this->source->putBackCharacter( $this->nextCharacter );
|
|
}
|
|
|
|
/**
|
|
* Reconsume the next character
|
|
*
|
|
* In more normal terms, this pushes a character back onto the data source
|
|
* so it will be read again for the next call to self::consumeCharacter().
|
|
*/
|
|
protected function reconsumeCharacter() {
|
|
// @codeCoverageIgnoreStart
|
|
if ( !is_string( $this->currentCharacter ) ) {
|
|
throw new \UnexpectedValueException( "[$this->line:$this->pos] Can't reconsume" );
|
|
}
|
|
// @codeCoverageIgnoreEnd
|
|
|
|
if ( $this->currentCharacter === DataSource::EOF ) {
|
|
// Huh?
|
|
return;
|
|
}
|
|
|
|
$this->source->putBackCharacter( $this->currentCharacter );
|
|
$this->nextCharacter = $this->currentCharacter;
|
|
$this->currentCharacter = (object)[];
|
|
$this->pos--;
|
|
}
|
|
|
|
/**
|
|
* Look ahead at the next three characters
|
|
* @return string[] Three characters
|
|
*/
|
|
protected function lookAhead() {
|
|
$ret = [
|
|
$this->nextChar(),
|
|
$this->nextChar(),
|
|
$this->nextChar(),
|
|
];
|
|
$this->source->putBackCharacter( $ret[2] );
|
|
$this->source->putBackCharacter( $ret[1] );
|
|
$this->source->putBackCharacter( $ret[0] );
|
|
|
|
return $ret;
|
|
}
|
|
|
|
public function getParseErrors() {
|
|
return $this->parseErrors;
|
|
}
|
|
|
|
public function clearParseErrors() {
|
|
$this->parseErrors = [];
|
|
}
|
|
|
|
/**
|
|
* Record a parse error
|
|
* @param string $tag Error tag
|
|
* @param array|null $position Report the error as starting at this
|
|
* position instead of at the current position.
|
|
* @param array $data Extra data about the error.
|
|
*/
|
|
protected function parseError( $tag, array $position = null, array $data = [] ) {
|
|
if ( $position ) {
|
|
if ( isset( $position['position'] ) ) {
|
|
$position = $position['position'];
|
|
}
|
|
if ( count( $position ) !== 2 || !is_int( $position[0] ) || !is_int( $position[1] ) ) {
|
|
// @codeCoverageIgnoreStart
|
|
throw new InvalidArgumentException( 'Invalid position' );
|
|
// @codeCoverageIgnoreEnd
|
|
}
|
|
$err = [ $tag, $position[0], $position[1] ];
|
|
} else {
|
|
$err = [ $tag, $this->line, $this->pos ];
|
|
}
|
|
$this->parseErrors[] = array_merge( $err, $data );
|
|
}
|
|
|
|
/**
|
|
* Read a token from the data source
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-token
|
|
* @return Token
|
|
*/
|
|
public function consumeToken() {
|
|
$this->consumeCharacter();
|
|
$pos = [ 'position' => [ $this->line, $this->pos ] ];
|
|
|
|
switch ( (string)$this->currentCharacter ) {
|
|
case "\n":
|
|
case "\t":
|
|
case ' ':
|
|
// Whitespace token
|
|
while ( self::isWhitespace( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
}
|
|
return new Token( Token::T_WHITESPACE, $pos );
|
|
|
|
case '"':
|
|
case '\'':
|
|
// String token
|
|
return $this->consumeStringToken( $this->currentCharacter, $pos );
|
|
|
|
case '#':
|
|
list( $next, $next2, $next3 ) = $this->lookAhead();
|
|
if ( self::isNameCharacter( $this->nextCharacter ) ||
|
|
self::isValidEscape( $next, $next2 )
|
|
) {
|
|
return new Token( Token::T_HASH, $pos + [
|
|
'typeFlag' => self::wouldStartIdentifier( $next, $next2, $next3 ) ? 'id' : 'unrestricted',
|
|
'value' => $this->consumeName(),
|
|
] );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case '$':
|
|
if ( $this->nextCharacter === '=' ) {
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_SUFFIX_MATCH, $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case '(':
|
|
return new Token( Token::T_LEFT_PAREN, $pos );
|
|
|
|
case ')':
|
|
return new Token( Token::T_RIGHT_PAREN, $pos );
|
|
|
|
case '*':
|
|
if ( $this->nextCharacter === '=' ) {
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_SUBSTRING_MATCH, $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case '+':
|
|
case '.':
|
|
list( $next, $next2, $next3 ) = $this->lookAhead();
|
|
if ( self::wouldStartNumber( $this->currentCharacter, $next, $next2 ) ) {
|
|
$this->reconsumeCharacter();
|
|
return $this->consumeNumericToken( $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case ',':
|
|
return new Token( Token::T_COMMA, $pos );
|
|
|
|
case '-':
|
|
list( $next, $next2, $next3 ) = $this->lookAhead();
|
|
if ( self::wouldStartNumber( $this->currentCharacter, $next, $next2 ) ) {
|
|
$this->reconsumeCharacter();
|
|
return $this->consumeNumericToken( $pos );
|
|
}
|
|
|
|
if ( $next === '-' && $next2 === '>' ) {
|
|
$this->consumeCharacter();
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_CDC, $pos );
|
|
}
|
|
|
|
if ( self::wouldStartIdentifier( $this->currentCharacter, $next, $next2 ) ) {
|
|
$this->reconsumeCharacter();
|
|
return $this->consumeIdentLikeToken( $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case '/':
|
|
if ( $this->nextCharacter === '*' ) {
|
|
$this->consumeCharacter();
|
|
$this->consumeCharacter();
|
|
while ( $this->currentCharacter !== DataSource::EOF &&
|
|
!( $this->currentCharacter === '*' && $this->nextCharacter === '/' )
|
|
) {
|
|
$this->consumeCharacter();
|
|
}
|
|
if ( $this->currentCharacter === DataSource::EOF ) {
|
|
// Parse error from the editor's draft as of 2017-01-06
|
|
$this->parseError( 'unclosed-comment', $pos );
|
|
}
|
|
$this->consumeCharacter();
|
|
return $this->consumeToken();
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case ':':
|
|
return new Token( Token::T_COLON, $pos );
|
|
|
|
case ';':
|
|
return new Token( Token::T_SEMICOLON, $pos );
|
|
|
|
case '<':
|
|
list( $next, $next2, $next3 ) = $this->lookAhead();
|
|
if ( $next === '!' && $next2 === '-' && $next3 === '-' ) {
|
|
$this->consumeCharacter();
|
|
$this->consumeCharacter();
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_CDO, $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case '@':
|
|
list( $next, $next2, $next3 ) = $this->lookAhead();
|
|
if ( self::wouldStartIdentifier( $next, $next2, $next3 ) ) {
|
|
return new Token( Token::T_AT_KEYWORD, $pos + [ 'value' => $this->consumeName() ] );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case '[':
|
|
return new Token( Token::T_LEFT_BRACKET, $pos );
|
|
|
|
case '\\':
|
|
if ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
|
|
$this->reconsumeCharacter();
|
|
return $this->consumeIdentLikeToken( $pos );
|
|
}
|
|
|
|
$this->parseError( 'bad-escape' );
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case ']':
|
|
return new Token( Token::T_RIGHT_BRACKET, $pos );
|
|
|
|
case '^':
|
|
if ( $this->nextCharacter === '=' ) {
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_PREFIX_MATCH, $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case '{':
|
|
return new Token( Token::T_LEFT_BRACE, $pos );
|
|
|
|
case '}':
|
|
return new Token( Token::T_RIGHT_BRACE, $pos );
|
|
|
|
case '0':
|
|
case '1':
|
|
case '2':
|
|
case '3':
|
|
case '4':
|
|
case '5':
|
|
case '6':
|
|
case '7':
|
|
case '8':
|
|
case '9':
|
|
$this->reconsumeCharacter();
|
|
return $this->consumeNumericToken( $pos );
|
|
|
|
case 'u':
|
|
case 'U':
|
|
if ( $this->nextCharacter === '+' ) {
|
|
list( $next, $next2 ) = $this->lookAhead();
|
|
if ( self::isHexDigit( $next2 ) || $next2 === '?' ) {
|
|
$this->consumeCharacter();
|
|
return $this->consumeUnicodeRangeToken( $pos );
|
|
}
|
|
}
|
|
|
|
$this->reconsumeCharacter();
|
|
return $this->consumeIdentLikeToken( $pos );
|
|
|
|
case '|':
|
|
if ( $this->nextCharacter === '=' ) {
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_DASH_MATCH, $pos );
|
|
}
|
|
|
|
if ( $this->nextCharacter === '|' ) {
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_COLUMN, $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case '~':
|
|
if ( $this->nextCharacter === '=' ) {
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_INCLUDE_MATCH, $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
|
|
case DataSource::EOF:
|
|
return new Token( Token::T_EOF, $pos );
|
|
|
|
default:
|
|
if ( self::isNameStartCharacter( $this->currentCharacter ) ) {
|
|
$this->reconsumeCharacter();
|
|
return $this->consumeIdentLikeToken( $pos );
|
|
}
|
|
|
|
return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Consume a numeric token
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-numeric-token
|
|
* @param array $data Data for the new token (typically contains just 'position')
|
|
* @return Token
|
|
*/
|
|
protected function consumeNumericToken( array $data ) {
|
|
list( $data['representation'], $data['value'], $data['typeFlag'] ) = $this->consumeNumber();
|
|
|
|
list( $next, $next2, $next3 ) = $this->lookAhead();
|
|
if ( self::wouldStartIdentifier( $next, $next2, $next3 ) ) {
|
|
return new Token( Token::T_DIMENSION, $data + [ 'unit' => $this->consumeName() ] );
|
|
} elseif ( $this->nextCharacter === '%' ) {
|
|
$this->consumeCharacter();
|
|
return new Token( Token::T_PERCENTAGE, $data );
|
|
} else {
|
|
return new Token( Token::T_NUMBER, $data );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Consume an ident-like token
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-an-ident-like-token
|
|
* @note Per the draft as of January 2017, quoted URLs are parsed as
|
|
* functions named 'url'. This is needed in order to implement the `<url>`
|
|
* type in the [Values specification](https://www.w3.org/TR/2016/CR-css-values-3-20160929/#urls).
|
|
* @param array $data Data for the new token (typically contains just 'position')
|
|
* @return Token
|
|
*/
|
|
protected function consumeIdentLikeToken( array $data ) {
|
|
$name = $this->consumeName();
|
|
|
|
if ( $this->nextCharacter === '(' ) {
|
|
$this->consumeCharacter();
|
|
|
|
if ( !strcasecmp( $name, 'url' ) ) {
|
|
while ( true ) {
|
|
list( $next, $next2 ) = $this->lookAhead();
|
|
if ( !self::isWhitespace( $next ) || !self::isWhitespace( $next2 ) ) {
|
|
break;
|
|
}
|
|
$this->consumeCharacter();
|
|
}
|
|
if ( $next !== '"' && $next !== '\'' &&
|
|
!( self::isWhitespace( $next ) && ( $next2 === '"' || $next2=== '\'' ) )
|
|
) {
|
|
return $this->consumeUrlToken( $data );
|
|
}
|
|
}
|
|
|
|
return new Token( Token::T_FUNCTION, $data + [ 'value' => $name ] );
|
|
}
|
|
|
|
return new Token( Token::T_IDENT, $data + [ 'value' => $name ] );
|
|
}
|
|
|
|
/**
|
|
* Consume a string token
|
|
*
|
|
* This assumes the leading quote or apostrophe has already been consumed.
|
|
*
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-string-token
|
|
* @param string $endChar Ending character of the string
|
|
* @param array $data Data for the new token (typically contains just 'position')
|
|
* @return Token
|
|
*/
|
|
protected function consumeStringToken( $endChar, array $data ) {
|
|
$data['value'] = '';
|
|
|
|
while ( true ) {
|
|
$this->consumeCharacter();
|
|
switch ( $this->currentCharacter ) {
|
|
case DataSource::EOF:
|
|
// Parse error from the editor's draft as of 2017-01-06
|
|
$this->parseError( 'unclosed-string', $data );
|
|
break 2;
|
|
|
|
case $endChar:
|
|
break 2;
|
|
|
|
case "\n":
|
|
$this->parseError( 'newline-in-string' );
|
|
$this->reconsumeCharacter();
|
|
return new Token( Token::T_BAD_STRING, [ 'value' => '' ] + $data );
|
|
|
|
case '\\':
|
|
if ( $this->nextCharacter === DataSource::EOF ) {
|
|
// Do nothing
|
|
// Parse error from the editor's draft as of 2017-01-06
|
|
$this->parseError( 'bad-escape' );
|
|
} elseif ( $this->nextCharacter === "\n" ) {
|
|
// Consume it
|
|
$this->consumeCharacter();
|
|
} elseif ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
|
|
$data['value'] .= $this->consumeEscape();
|
|
} else {
|
|
// @codeCoverageIgnoreStart
|
|
throw new \UnexpectedValueException( "[$this->line:$this->pos] Unexpected state" );
|
|
// @codeCoverageIgnoreEnd
|
|
}
|
|
break;
|
|
|
|
default:
|
|
$data['value'] .= $this->currentCharacter;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return new Token( Token::T_STRING, $data );
|
|
}
|
|
|
|
/**
|
|
* Consume a URL token
|
|
*
|
|
* This assumes the leading "url(" has already been consumed.
|
|
*
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-url-token
|
|
* @note Per the draft as of January 2017, this does not handle quoted URL tokens.
|
|
* @param array $data Data for the new token (typically contains just 'position')
|
|
* @return Token
|
|
*/
|
|
protected function consumeUrlToken( array $data ) {
|
|
// 1.
|
|
$data['value'] = '';
|
|
|
|
// 2.
|
|
while ( self::isWhitespace( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
}
|
|
|
|
// 3.
|
|
if ( $this->nextCharacter === DataSource::EOF ) {
|
|
// Parse error from the editor's draft as of 2017-01-06
|
|
$this->parseError( 'unclosed-url', $data );
|
|
return new Token( Token::T_URL, $data );
|
|
}
|
|
|
|
// 4. (removed in draft, this was formerly the parsing for a quoted URL token)
|
|
|
|
// 5. (renumbered as 4 in the draft)
|
|
while ( true ) {
|
|
$this->consumeCharacter();
|
|
switch ( $this->currentCharacter ) {
|
|
case DataSource::EOF:
|
|
// Parse error from the editor's draft as of 2017-01-06
|
|
$this->parseError( 'unclosed-url', $data );
|
|
break 2;
|
|
|
|
case ')':
|
|
break 2;
|
|
|
|
case "\n":
|
|
case "\t":
|
|
case ' ':
|
|
while ( self::isWhitespace( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
}
|
|
if ( $this->nextCharacter === ')' ) {
|
|
$this->consumeCharacter();
|
|
break 2;
|
|
} elseif ( $this->nextCharacter === DataSource::EOF ) {
|
|
// Parse error from the editor's draft as of 2017-01-06
|
|
$this->consumeCharacter();
|
|
$this->parseError( 'unclosed-url', $data );
|
|
break 2;
|
|
} else {
|
|
$this->consumeBadUrlRemnants();
|
|
return new Token( Token::T_BAD_URL, [ 'value' => '' ] + $data );
|
|
}
|
|
break;
|
|
|
|
case '"':
|
|
case '\'':
|
|
case '(':
|
|
$this->parseError( 'bad-character-in-url' );
|
|
$this->consumeBadUrlRemnants();
|
|
return new Token( Token::T_BAD_URL, [ 'value' => '' ] + $data );
|
|
|
|
case '\\':
|
|
if ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
|
|
$data['value'] .= $this->consumeEscape();
|
|
} else {
|
|
$this->parseError( 'bad-escape' );
|
|
$this->consumeBadUrlRemnants();
|
|
return new Token( Token::T_BAD_URL, [ 'value' => '' ] + $data );
|
|
}
|
|
break;
|
|
|
|
default:
|
|
if ( self::isNonPrintable( $this->currentCharacter ) ) {
|
|
$this->parseError( 'bad-character-in-url' );
|
|
$this->consumeBadUrlRemnants();
|
|
return new Token( Token::T_BAD_URL, [ 'value' => '' ] + $data );
|
|
}
|
|
|
|
$data['value'] .= $this->currentCharacter;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return new Token( Token::T_URL, $data );
|
|
}
|
|
|
|
/**
|
|
* Clean up after finding an error in a URL
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-the-remnants-of-a-bad-url
|
|
*/
|
|
protected function consumeBadUrlRemnants() {
|
|
while ( true ) {
|
|
$this->consumeCharacter();
|
|
if ( $this->currentCharacter === ')' || $this->currentCharacter === DataSource::EOF ) {
|
|
break;
|
|
}
|
|
if ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
|
|
$this->consumeEscape();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Consume a unicode-range token
|
|
*
|
|
* This assumes the initial "u" has been consumed (currentCharacter is the '+'),
|
|
* and the next codepoint is verfied to be a hex digit or "?".
|
|
*
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-unicode-range-token
|
|
* @param array $data Data for the new token (typically contains just 'position')
|
|
* @return Token
|
|
*/
|
|
protected function consumeUnicodeRangeToken( array $data ) {
|
|
// 1.
|
|
$v = '';
|
|
while ( strlen( $v ) < 6 && self::isHexDigit( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
$v .= $this->currentCharacter;
|
|
}
|
|
$anyQ = false;
|
|
while ( strlen( $v ) < 6 && $this->nextCharacter === '?' ) {
|
|
$anyQ = true;
|
|
$this->consumeCharacter();
|
|
$v .= $this->currentCharacter;
|
|
}
|
|
|
|
if ( $anyQ ) {
|
|
return new Token( Token::T_UNICODE_RANGE, $data + [
|
|
'start' => intval( str_replace( '?', '0', $v ), 16 ),
|
|
'end' => intval( str_replace( '?', 'F', $v ), 16 ),
|
|
] );
|
|
}
|
|
|
|
$data['start'] = intval( $v, 16 );
|
|
|
|
// 2.
|
|
list( $next, $next2 ) = $this->lookAhead();
|
|
if ( $next === '-' && self::isHexDigit( $next2 ) ) {
|
|
$this->consumeCharacter();
|
|
$v = '';
|
|
while ( strlen( $v ) < 6 && self::isHexDigit( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
$v .= $this->currentCharacter;
|
|
}
|
|
$data['end'] = intval( $v, 16 );
|
|
} else {
|
|
// 3.
|
|
$data['end'] = $data['start'];
|
|
}
|
|
|
|
// 4.
|
|
return new Token( Token::T_UNICODE_RANGE, $data );
|
|
}
|
|
|
|
/**
|
|
* Indicate if a character is whitespace
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#whitespace
|
|
* @param string $char A single UTF-8 character
|
|
* @return bool
|
|
*/
|
|
protected static function isWhitespace( $char ) {
|
|
return $char === "\n" || $char === "\t" || $char === " ";
|
|
}
|
|
|
|
/**
|
|
* Indicate if a character is a name-start code point
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#name-start-code-point
|
|
* @param string $char A single UTF-8 character
|
|
* @return bool
|
|
*/
|
|
protected static function isNameStartCharacter( $char ) {
|
|
// Every non-ASCII character is a name start character, so we can just
|
|
// check the first byte.
|
|
$char = ord( $char );
|
|
return $char >= 0x41 && $char <= 0x5a ||
|
|
$char >= 0x61 && $char <= 0x7a ||
|
|
$char >= 0x80 || $char === 0x5f;
|
|
}
|
|
|
|
/**
|
|
* Indicate if a character is a name code point
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#name-code-point
|
|
* @param string $char A single UTF-8 character
|
|
* @return bool
|
|
*/
|
|
protected static function isNameCharacter( $char ) {
|
|
// Every non-ASCII character is a name character, so we can just check
|
|
// the first byte.
|
|
$char = ord( $char );
|
|
return $char >= 0x41 && $char <= 0x5a ||
|
|
$char >= 0x61 && $char <= 0x7a ||
|
|
$char >= 0x30 && $char <= 0x39 ||
|
|
$char >= 0x80 || $char === 0x5f || $char === 0x2d;
|
|
}
|
|
|
|
/**
|
|
* Indicate if a character is non-printable
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#non-printable-code-point
|
|
* @param string $char A single UTF-8 character
|
|
* @return bool
|
|
*/
|
|
protected static function isNonPrintable( $char ) {
|
|
// No non-ASCII character is non-printable, so we can just check the
|
|
// first byte.
|
|
$char = ord( $char );
|
|
return $char >= 0x00 && $char <= 0x08 ||
|
|
$char === 0x0b ||
|
|
$char >= 0x0e && $char <= 0x1f ||
|
|
$char === 0x7f;
|
|
}
|
|
|
|
/**
|
|
* Indicate if a character is a digit
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#digit
|
|
* @param string $char A single UTF-8 character
|
|
* @return bool
|
|
*/
|
|
protected static function isDigit( $char ) {
|
|
// No non-ASCII character is a digit, so we can just check the first
|
|
// byte.
|
|
$char = ord( $char );
|
|
return $char >= 0x30 && $char <= 0x39;
|
|
}
|
|
|
|
/**
|
|
* Indicate if a character is a hex digit
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#hex-digit
|
|
* @param string $char A single UTF-8 character
|
|
* @return bool
|
|
*/
|
|
protected static function isHexDigit( $char ) {
|
|
// No non-ASCII character is a hex digit, so we can just check the
|
|
// first byte.
|
|
$char = ord( $char );
|
|
return $char >= 0x30 && $char <= 0x39 ||
|
|
$char >= 0x41 && $char <= 0x46 ||
|
|
$char >= 0x61 && $char <= 0x66;
|
|
}
|
|
|
|
/**
|
|
* Determine if two characters constitute a valid escape
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#starts-with-a-valid-escape
|
|
* @param string $char1
|
|
* @param string $char2
|
|
* @return bool
|
|
*/
|
|
protected static function isValidEscape( $char1, $char2 ) {
|
|
return $char1 === '\\' && $char2 !== "\n";
|
|
}
|
|
|
|
/**
|
|
* Determine if three characters would start an identifier
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#would-start-an-identifier
|
|
* @param string $char1
|
|
* @param string $char2
|
|
* @param string $char3
|
|
* @return bool
|
|
*/
|
|
protected static function wouldStartIdentifier( $char1, $char2, $char3 ) {
|
|
if ( $char1 === '-' ) {
|
|
// Added the possibility for an itentifier beginning with "--" per the draft.
|
|
return self::isNameStartCharacter( $char2 ) || $char2 === '-' ||
|
|
self::isValidEscape( $char2, $char3 );
|
|
} elseif ( self::isNameStartCharacter( $char1 ) ) {
|
|
return true;
|
|
} elseif ( $char1 === '\\' ) {
|
|
return self::isValidEscape( $char1, $char2 );
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if three characters would start a number
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#starts-with-a-number
|
|
* @param string $char1
|
|
* @param string $char2
|
|
* @param string $char3
|
|
* @return bool
|
|
*/
|
|
protected static function wouldStartNumber( $char1, $char2, $char3 ) {
|
|
if ( $char1 === '+' || $char1 === '-' ) {
|
|
return self::isDigit( $char2 ) ||
|
|
$char2 === '.' && self::isDigit( $char3 );
|
|
} elseif ( $char1 === '.' ) {
|
|
return self::isDigit( $char2 );
|
|
// @codeCoverageIgnoreStart
|
|
// Nothing reaches this code
|
|
} else {
|
|
return self::isDigit( $char1 );
|
|
}
|
|
// @codeCoverageIgnoreEnd
|
|
}
|
|
|
|
/**
|
|
* Consume a valid escape
|
|
*
|
|
* This assumes the leading backslash is consumed.
|
|
*
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-an-escaped-code-point
|
|
* @return string Escaped character
|
|
*/
|
|
protected function consumeEscape() {
|
|
$position = [ 'position' => [ $this->line, $this->pos ] ];
|
|
|
|
$this->consumeCharacter();
|
|
|
|
// @codeCoverageIgnoreStart
|
|
if ( $this->currentCharacter === "\n" ) {
|
|
throw new \UnexpectedValueException( "[$this->line:$this->pos] Unexpected newline" );
|
|
}
|
|
// @codeCoverageIgnoreEnd
|
|
|
|
// 1-6 hexits, plus one optional whitespace character
|
|
if ( self::isHexDigit( $this->currentCharacter ) ) {
|
|
$num = $this->currentCharacter;
|
|
while ( strlen( $num ) < 6 && self::isHexDigit( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
$num .= $this->currentCharacter;
|
|
}
|
|
if ( self::isWhitespace( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
}
|
|
|
|
$num = intval( $num, 16 );
|
|
if ( $num === 0 || $num >= 0xd800 && $num <= 0xdfff || $num > 0x10ffff ) {
|
|
return \UtfNormal\Constants::UTF8_REPLACEMENT;
|
|
}
|
|
return \UtfNormal\Utils::codepointToUtf8( $num );
|
|
}
|
|
|
|
if ( $this->currentCharacter === DataSource::EOF ) {
|
|
// Parse error from the editor's draft as of 2017-01-06
|
|
$this->parseError( 'bad-escape', $position );
|
|
return \UtfNormal\Constants::UTF8_REPLACEMENT;
|
|
}
|
|
|
|
return $this->currentCharacter;
|
|
}
|
|
|
|
/**
|
|
* Consume a name
|
|
*
|
|
* Note this does not do validation on the input stream. Call
|
|
* self::wouldStartIdentifier() or the like before calling the method if
|
|
* necessary.
|
|
*
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-name
|
|
* @return string Name
|
|
*/
|
|
protected function consumeName() {
|
|
$name = '';
|
|
|
|
while ( true ) {
|
|
$this->consumeCharacter();
|
|
|
|
if ( self::isNameCharacter( $this->currentCharacter ) ) {
|
|
$name .= $this->currentCharacter;
|
|
} elseif ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
|
|
$name .= $this->consumeEscape();
|
|
} else {
|
|
$this->reconsumeCharacter(); // Doesn't say to, but breaks otherwise
|
|
return $name;
|
|
}
|
|
}
|
|
// @codeCoverageIgnoreStart
|
|
}
|
|
// @codeCoverageIgnoreEnd
|
|
|
|
/**
|
|
* Consume a number
|
|
*
|
|
* Note this does not do validation on the input stream. Call
|
|
* self::wouldStartNumber() before calling the method if necessary.
|
|
*
|
|
* @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-number
|
|
* @return array [ string $value, int|float $number, string $type ('integer' or 'number') ]
|
|
*/
|
|
protected function consumeNumber() {
|
|
// 1.
|
|
$repr = '';
|
|
$type = 'integer';
|
|
|
|
// 2.
|
|
if ( $this->nextCharacter === '+' || $this->nextCharacter === '-' ) {
|
|
$this->consumeCharacter();
|
|
$repr .= $this->currentCharacter;
|
|
}
|
|
|
|
// 3.
|
|
while ( self::isDigit( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
$repr .= $this->currentCharacter;
|
|
}
|
|
|
|
// 4.
|
|
if ( $this->nextCharacter === '.' ) {
|
|
list( $next, $next2, $next3 ) = $this->lookAhead();
|
|
if ( self::isDigit( $next2 ) ) {
|
|
// 4.1.
|
|
$this->consumeCharacter();
|
|
$this->consumeCharacter();
|
|
// 4.2.
|
|
$repr .= $next . $next2;
|
|
// 4.3.
|
|
$type = 'number';
|
|
// 4.4.
|
|
while ( self::isDigit( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
$repr .= $this->currentCharacter;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5.
|
|
if ( $this->nextCharacter === 'e' || $this->nextCharacter === 'E' ) {
|
|
list( $next, $next2, $next3 ) = $this->lookAhead();
|
|
$ok = false;
|
|
if ( ( $next2 === '+' || $next2 === '-' ) && self::isDigit( $next3 ) ) {
|
|
$ok = true;
|
|
// 5.1.
|
|
$this->consumeCharacter();
|
|
$this->consumeCharacter();
|
|
$this->consumeCharacter();
|
|
// 5.2.
|
|
$repr .= $next . $next2 . $next3;
|
|
} elseif ( self::isDigit( $next2 ) ) {
|
|
$ok = true;
|
|
// 5.1.
|
|
$this->consumeCharacter();
|
|
$this->consumeCharacter();
|
|
// 5.2.
|
|
$repr .= $next . $next2;
|
|
}
|
|
if ( $ok ) {
|
|
// 5.3.
|
|
$type = 'number';
|
|
// 5.4.
|
|
while ( self::isDigit( $this->nextCharacter ) ) {
|
|
$this->consumeCharacter();
|
|
$repr .= $this->currentCharacter;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. We assume PHP's casting follows the same rules as
|
|
// https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#convert-a-string-to-a-number
|
|
$value = $type === 'integer' ? (int)$repr : (float)$repr;
|
|
|
|
// 7.
|
|
return [ $repr, $value, $type ];
|
|
}
|
|
}
|