diff --git a/gutenberg.php b/gutenberg.php index fd37d4b9354989..b0d3c7c59d516e 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -20,6 +20,7 @@ require_once dirname( __FILE__ ) . '/lib/client-assets.php'; require_once dirname( __FILE__ ) . '/lib/i18n.php'; require_once dirname( __FILE__ ) . '/lib/parser.php'; + require_once dirname( __FILE__ ) . '/lib/block-parser.php'; require_once dirname( __FILE__ ) . '/lib/register.php'; // Register server-side code for individual blocks. diff --git a/lib/block-parser.php b/lib/block-parser.php new file mode 100644 index 00000000000000..670e78acb52172 --- /dev/null +++ b/lib/block-parser.php @@ -0,0 +1,205 @@ +)'; + const BLOCK_NAME = '(^[[:alpha:]](?:[[:alnum:]]|/[[:alnum:]])*)i'; + const BLOCK_ATTRIBUTES = '(^{(?:((?!}[ \t\r\n]+/?-->).)*)})'; + const WS = '(^[ \t\r\n])'; + const WSS = '(^[ \t\r\n]+)'; + + const MAX_RUNTIME = 1; // give up after one second + + public function parse($input) { + $tic = microtime( true ); + + // trampoline for stack-safe recursion of the actual parser + while ( $this->input && ( microtime( true ) - $tic ) < self::MAX_RUNTIME ) { + return $this->proceed( $input ); + } + } + + public function proceed( $input ) { + return succeed( 'test', $input ); + } + + public static function block_void( $input ) { + $result = self::sequence( array( + array( 'self::ignore', array( 'self::match', array( '(^)' ) ) ), + array( 'self::sequence', array( array( + array( 'self::ignore', array( 'self::match', array( self::WSS ) ) ), + array( 'self::match', array( self::BLOCK_ATTRIBUTES ) ), + array( 'self::ignore', array( 'self::match', array( '(^[ \t\r\n]+/-->)' ) ) ) + ) ) ) + ) ) ) + ), $input ); + + if ( empty( $result ) ) { + return array(); + } + + list( list( list( $blockName ), list( list( $raw_attrs ) ) ), $remaining ) = $result; + $attrs = $raw_attrs + ? json_decode( $raw_attrs, true ) + : array(); + + return array( self::block( $blockName, $attrs, '' ), $remaining ); + } + + //----------------------------------------- + // Parser a :: String -> [ ( a, String ) ] + // + // A parser is a function which takes a string + // and returns a list of things and strings + // + // An empty list is a failed parse + // + // The polymorphic "a" will eventually be a block + //----------------------------------------- + public static function succeed( $value, $input ) { + return array( array( $value, $input ) ); + } + + public static function fail( $input ) { + return array(); + } + + public static function ignore( $parser, $parser_args, $input ) { + $result = call_user_func_array( $parser, array_merge( $parser_args, array( $input ) ) ); + + if ( empty( $result ) ) { + return array(); + } + + list( /* production */, $remaining ) = $result; + + return array( array(), $remaining ); + } + + public static function literal( $value, $input ) { + return strpos( $input, $value ) === 0 + ? array( $value, substr( $input, strlen( $value ) ) ) + : array(); + } + + public static function match( $pattern, $input ) { + $matches = array(); + + $is_match = preg_match( $pattern, $input, $matches ); + + return $is_match + ? array( $matches, substr( $input, strlen( $matches[ 0 ] ) ) ) + : array(); + } + + public static function map( $f, $parser, $parser_args, $input ) { + $result = call_user_func_array( + $f, + call_user_func_array( + $parser, + array_merge( $parser_args, array( $input ) ) + ) + ); + + return ! empty( $result ) + ? array( $result[ 0 ], $input ) + : array(); + } + + public static function sequence( $parsers_and_args, $input ) { + $output = array(); + $remaining = $input; + + foreach ( $parsers_and_args as $parser_and_args ) { + list( $parser, $parser_args ) = $parser_and_args; + + $result = call_user_func_array( $parser, array_merge( $parser_args, array( $remaining ) ) ); + + if ( empty( $result ) ) { + return array(); + } + + list( $next, $remaining ) = $result; + $output[] = $next; + } + + return array( array_values( array_filter( $output, 'self::is_not_empty' ) ), $remaining ); + } + + public static function is_not_empty( $value ) { + return ! empty( $value ); + } + + public static function first_of( $parsers_and_args, $input ) { + foreach ( $parsers_and_args as $parser_and_args ) { + list( $parser, $parser_args ) = $parser_and_args; + + $result = call_user_func_array( $parser, array_merge( $parser_args, array( $input ) ) ); + + if ( ! empty( $result ) ) { + return $result; + } + } + + return array(); + } + + public static function zero_or_more( $parser, $parser_args, $input ) { + $output = array(); + $remaining = $input; + + while ( true ) { + $result = call_user_func_array( $parser, array_merge( $parser_args, array( $remaining ) ) ); + if ( empty( $result ) ) { + return array( $output, $remaining ); + } + + list( $next, $remaining ) = $result; + $output[] = $next; + } + } + + public static function one_or_more( $parser, $parser_args, $input ) { + $output = array(); + $remaining = $input; + + while ( true ) { + $result = call_user_func_array( $parser, array_merge( $parser_args, array( $remaining ) ) ); + if ( empty( $result ) ) { + return empty( $output ) + ? array() + : array( $output, $remaining ); + } + + list( $next, $remaining ) = $result; + $output[] = $next; + } + } + + public static function block( $blockName, $attrs, $rawContent ) { + return array( + 'blockName' => $blockName, + 'attrs' => $attrs, + 'rawContent' => $rawContent + ); + } + + public static function freeform( $rawContent ) { + return self::block( 'freeform', array(), $rawContent ); + } +} + +endif; diff --git a/phpunit/class.block-parser-test.php b/phpunit/class.block-parser-test.php new file mode 100644 index 00000000000000..ec854c15abefb0 --- /dev/null +++ b/phpunit/class.block-parser-test.php @@ -0,0 +1,185 @@ +parser->parse( $input ); + } + + function setUp() { + $this->parser = new Gutenberg_Block_Parser(); + } + + function test_combinator_succeed() { + $this->assertEquals( + [ [ 'test', 'bork' ] ], + Gutenberg_Block_Parser::succeed( 'test', 'bork' ) + ); + } + + function test_combinator_fail() { + $this->assertEquals( + [], + Gutenberg_Block_Parser::fail( 'bork' ) + ); + } + + function test_combinator_literal_success() { + $this->assertEquals( + [ 'test', ' string' ], + Gutenberg_Block_Parser::literal( 'test', 'test string' ) + ); + } + + function test_combinator_literal_fail() { + $this->assertEquals( + [], + Gutenberg_Block_Parser::literal( 'test', 'no match' ) + ); + } + + function test_combinator_ignore() { + $this->assertEquals( + [ [], 'abc' ], + Gutenberg_Block_Parser::ignore( + [ 'Gutenberg_Block_Parser', 'literal' ], + [ '123' ], + '123abc' + ) + ); + } + + function test_combinator_ignore_fail() { + $this->assertEquals( + [], + Gutenberg_Block_Parser::ignore( + [ 'Gutenberg_Block_Parser', 'literal' ], + [ 'abc' ], + '123abc' + ) + ); + } + + function test_combinator_match_success() { + $this->assertEquals( + [ [ 'test_val' ], ' = 5' ], + Gutenberg_Block_Parser::match( '(^[a-z_]+)', 'test_val = 5' ) + ); + } + + function test_combinator_match_groups_success() { + $this->assertEquals( + [ [ 'test_val = 5', 'test_val', '5' ], ';' ], + Gutenberg_Block_Parser::match( '(^([a-z_]+) = (\d+))', 'test_val = 5;' ) + ); + } + + function test_combinator_match_fail() { + $this->assertEquals( + [], + Gutenberg_Block_Parser::match( '(^[a-z_]+)', ';test_val = 5' ) + ); + } + + function test_combinator_zero_or_more() { + $this->assertEquals( + [ [ 'a', 'a', 'a' ], 'xyz' ], + Gutenberg_Block_Parser::zero_or_more( + [ 'Gutenberg_Block_Parser', 'literal' ], + [ 'a' ], + 'aaaxyz' + ) + ); + } + + function test_combinator_zero_or_more_failure() { + $this->assertEquals( + [ [], 'bbb' ], + Gutenberg_Block_Parser::zero_or_more( + [ 'Gutenberg_Block_Parser', 'literal' ], + [ 'a' ], + 'bbb' + ) + ); + } + + function test_combinator_one_or_more() { + $this->assertEquals( + [ [ 'a', 'a' ], 'bb' ], + Gutenberg_Block_Parser::one_or_more( + [ 'Gutenberg_Block_Parser', 'literal' ], + [ 'a' ], + 'aabb' + ) + ); + } + + function test_combinator_one_or_more_failure() { + $this->assertEquals( + [], + Gutenberg_Block_Parser::one_or_more( + [ 'Gutenberg_Block_Parser', 'literal' ], + [ 'a' ], + 'bbb' + ) + ); + } + + function test_combinator_sequence() { + $this->assertEquals( + [ [ 'a', 'b' ], 'cd' ], + Gutenberg_Block_Parser::sequence( [ + [ [ 'Gutenberg_Block_Parser', 'literal' ], [ 'a' ] ], + [ [ 'Gutenberg_Block_Parser', 'literal' ], [ 'b' ] ], + ], 'abcd' ) + ); + } + + function test_combinator_sequence_failure() { + $this->assertEquals( + [], + Gutenberg_Block_Parser::sequence( [ + [ [ 'Gutenberg_Block_Parser', 'literal' ], [ 'a' ] ], + [ [ 'Gutenberg_Block_Parser', 'literal' ], [ 'b' ] ], + ], 'acd' ) + ); + } + + function test_block_void_no_attrs() { + $this->assertEquals( + [ [ 'blockName' => 'core/void', 'attrs' => [], 'rawContent' => '' ], '' ], + Gutenberg_Block_Parser::block_void( + '' + ) + ); + } + + function test_block_void_with_empty_attrs() { + $this->assertEquals( + [ [ 'blockName' => 'core/void', 'attrs' => [], 'rawContent' => '' ], '' ], + Gutenberg_Block_Parser::block_void( + '' + ) + ); + } + + function test_block_void_with_non_empty_attrs() { + $this->assertEquals( + [ [ + 'blockName' => 'core/void', + 'attrs' => [ + 'val' => 1337 + ], + 'rawContent' => '' + ], '' ], + Gutenberg_Block_Parser::block_void( + '' + ) + ); + } +}