This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,140 @@
<?php
/**
* A parser grammar.
*
* This class represents a parser grammar that can be consumed by WP_Parser.
* It loads a compressed grammar from a PHP array, inflates it to an internal
* representation, and precomputes a lookup table for quick branch selection.
*
* @TODO: Add more details about the grammar implementation.
*/
class WP_Parser_Grammar {
/**
* ID for a special grammar rule that represents an empty "ε" (epsilon) rule.
*
* An "ε" rule in a grammar is a rule that matches an empty input of 0 bytes.
* It can be used to represent optional grammar productions, and it is helpful
* for expanding 0-or-1, 0-or-more, and 1-or-more quantifiers into simple rules.
*
* @TODO Investigate whether we can prevent possible conflict with a token ID.
* The MySQL grammar doesn't define a token with ID "0", but generally
* token IDs are not guaranteed to always satisfy this condition.
*/
const EMPTY_RULE_ID = 0;
/**
* @TODO: Review and document these properties and their visibility.
*/
public $rules;
public $rule_names;
public $fragment_ids;
public $lookahead_is_match_possible = array();
public $lowest_non_terminal_id;
public $highest_terminal_id;
public $native_grammar;
public function __construct( array $rules ) {
$this->inflate( $rules );
}
public function get_rule_name( $rule_id ) {
return $this->rule_names[ $rule_id ];
}
public function get_rule_id( $rule_name ) {
return array_search( $rule_name, $this->rule_names, true );
}
/**
* Inflate the grammar to an internal representation optimized for parsing.
*
* The input grammar is a compressed PHP array to minimize the file size.
* Every rule and token in the compressed grammar is encoded as an integer.
*/
private function inflate( $grammar ) {
$this->lowest_non_terminal_id = $grammar['rules_offset'];
$this->highest_terminal_id = $this->lowest_non_terminal_id - 1;
foreach ( $grammar['rules_names'] as $rule_index => $rule_name ) {
$this->rule_names[ $rule_index + $grammar['rules_offset'] ] = $rule_name;
$this->rules[ $rule_index + $grammar['rules_offset'] ] = array();
/**
* Treat all intermediate rules as fragments to inline before returning
* the final parse tree to the API consumer.
*
* The original grammar was too difficult to parse with rules like:
*
* query ::= EOF | ((simpleStatement | beginWork) ((SEMICOLON_SYMBOL EOF?) | EOF))
*
* We've factored rule fragments, such as `EOF?`, into separate rules, such as `%EOF_zero_or_one`.
* This is super useful for parsing, but it limits the API consumer's ability to
* reason about the parse tree.
*
* Fragments are intermediate rules that are not part of the original grammar.
* They are prefixed with a "%" to be distinguished from the original rules.
*/
if ( '%' === $rule_name[0] ) {
$this->fragment_ids[ $rule_index + $grammar['rules_offset'] ] = true;
}
}
$this->rules = array();
foreach ( $grammar['grammar'] as $rule_index => $branches ) {
$rule_id = $rule_index + $grammar['rules_offset'];
$this->rules[ $rule_id ] = $branches;
}
/**
* Compute a rule => [token => true] lookup table for each rule
* that starts with a terminal OR with another rule that already
* has a lookahead mapping.
*
* This is similar to left-factoring the grammar, even if not quite
* the same.
*
* This enables us to quickly bail out from checking branches that
* cannot possibly match the current token. This increased the parser
* speed by a whopping 80%!
*
* @TODO: Explore these possible next steps:
*
* * Compute a rule => [token => branch[]] list lookup table and only
* process the branches that have a chance of matching the current token.
* * Actually left-factor the grammar as much as possible. This, however,
* could inflate the serialized grammar size.
*/
// 5 iterations seem to give us all the speed gains we can get from this.
for ( $i = 0; $i < 5; $i++ ) {
foreach ( $grammar['grammar'] as $rule_index => $branches ) {
$rule_id = $rule_index + $grammar['rules_offset'];
if ( isset( $this->lookahead_is_match_possible[ $rule_id ] ) ) {
continue;
}
$rule_lookup = array();
$first_symbol_can_be_expanded_to_all_terminals = true;
foreach ( $branches as $branch ) {
$terminals = false;
$branch_starts_with_terminal = $branch[0] < $this->lowest_non_terminal_id;
if ( $branch_starts_with_terminal ) {
$terminals = array( $branch[0] );
} elseif ( isset( $this->lookahead_is_match_possible[ $branch[0] ] ) ) {
$terminals = array_keys( $this->lookahead_is_match_possible[ $branch[0] ] );
}
if ( false === $terminals ) {
$first_symbol_can_be_expanded_to_all_terminals = false;
break;
}
foreach ( $terminals as $terminal ) {
$rule_lookup[ $terminal ] = true;
}
}
if ( $first_symbol_can_be_expanded_to_all_terminals ) {
$this->lookahead_is_match_possible[ $rule_id ] = $rule_lookup;
}
}
}
}
}
@@ -0,0 +1,384 @@
<?php
/**
* A node in parse tree.
*
* This class represents a node in the parse tree that is produced by WP_Parser.
* A node corresponds to the related grammar rule that was matched by the parser.
* Each node can contain children, consisting of other nodes and grammar tokens.
* In this way, a parser node constitutes a recursive structure that represents
* a parse (sub)tree at each level of the full grammar tree.
*/
class WP_Parser_Node {
/**
* @TODO: Review and document these properties and their visibility.
*/
public $rule_id;
public $rule_name;
protected $children = array();
public function __construct( $rule_id, $rule_name ) {
$this->rule_id = $rule_id;
$this->rule_name = $rule_name;
}
public function append_child( $node ) {
$this->children[] = $node;
}
/**
* Flatten the matched rule fragments as if their children were direct
* descendants of the current rule.
*
* What are rule fragments?
*
* When we initially parse the grammar file, it has compound rules such
* as this one:
*
* query ::= EOF | ((simpleStatement | beginWork) ((SEMICOLON_SYMBOL EOF?) | EOF))
*
* Building a parser that can understand such rules is way more complex than building
* a parser that only follows simple rules, so we flatten those compound rules into
* simpler ones. The above rule would be flattened to:
*
* query ::= EOF | %query0
* %query0 ::= %%query01 %%query02
* %%query01 ::= simpleStatement | beginWork
* %%query02 ::= SEMICOLON_SYMBOL EOF_zero_or_one | EOF
* EOF_zero_or_one ::= EOF | ε
*
* This factorization happens in "convert-grammar.php".
*
* "Fragments" are intermediate artifacts whose names are not in the original grammar.
* They are extremely useful for the parser, but the API consumer should never have to
* worry about them. Fragment names start with a percent sign ("%").
*
* The code below inlines every fragment back in its parent rule.
*
* We could optimize this. The current $match may be discarded later on so any inlining
* effort here would be wasted. However, inlining seems cheap and doing it bottom-up here
* is **much** easier than reprocessing the parse tree top-down later on.
*
* The following parse tree:
*
* [
* 'query' => [
* [
* '%query01' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ],
* '%query02' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ],
* ]
* ]
* ]
* ]
* ]
*
* Would be inlined as:
*
* [
* 'query' => [
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ],
* [
* 'simpleStatement' => [
* MySQLToken(MySQLLexer::WITH_SYMBOL, 'WITH')
* ]
* ]
* ]
* ]
*/
public function merge_fragment( $node ) {
$this->children = array_merge( $this->children, $node->children );
}
/**
* Check if this node has any child nodes or tokens.
*
* @return bool True if this node has any child nodes or tokens, false otherwise.
*/
public function has_child(): bool {
return count( $this->children ) > 0;
}
/**
* Check if this node has any child nodes.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return bool True if any child nodes are found, false otherwise.
*/
public function has_child_node( ?string $rule_name = null ): bool {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
return true;
}
}
return false;
}
/**
* Check if this node has any child tokens.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return bool True if any child tokens are found, false otherwise.
*/
public function has_child_token( ?int $token_id = null ): bool {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
return true;
}
}
return false;
}
/**
* Get the first child node or token of this node.
*
* @return WP_Parser_Node|WP_Parser_Token|null The first child node or token;
* null when no children are found.
*/
public function get_first_child() {
return $this->children[0] ?? null;
}
/**
* Get the first child node of this node.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node|null The first matching child node; null when no children are found.
*/
public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_Node {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
return $child;
}
}
return null;
}
/**
* Get the first child token of this node.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token|null The first matching child token; null when no children are found.
*/
public function get_first_child_token( ?int $token_id = null ): ?WP_Parser_Token {
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
return $child;
}
}
return null;
}
/**
* Get the first descendant node of this node.
*
* The node children are traversed recursively in a depth-first order until
* a matching descendant node is found, or the entire subtree is searched.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node|null The first matching descendant node; null when no descendants are found.
*/
public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Parser_Node {
for ( $i = 0; $i < count( $this->children ); $i++ ) {
$child = $this->children[ $i ];
if ( ! $child instanceof WP_Parser_Node ) {
continue;
}
if ( null === $rule_name || $child->rule_name === $rule_name ) {
return $child;
}
$node = $child->get_first_descendant_node( $rule_name );
if ( $node ) {
return $node;
}
}
return null;
}
/**
* Get the first descendant token of this node.
*
* The node children are traversed recursively in a depth-first order until
* a matching descendant token is found, or the entire subtree is searched.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token|null The first matching descendant token; null when no descendants are found.
*/
public function get_first_descendant_token( ?int $token_id = null ): ?WP_Parser_Token {
for ( $i = 0; $i < count( $this->children ); $i++ ) {
$child = $this->children[ $i ];
if ( $child instanceof WP_Parser_Token ) {
if ( null === $token_id || $child->id === $token_id ) {
return $child;
}
} else {
$token = $child->get_first_descendant_token( $token_id );
if ( $token ) {
return $token;
}
}
}
return null;
}
/**
* Get all children of this node.
*
* @return array<WP_Parser_Node|WP_Parser_Token> An array of all child nodes and tokens of this node.
*/
public function get_children(): array {
return $this->children;
}
/**
* Get all child nodes of this node.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node[] An array of all matching child nodes.
*/
public function get_child_nodes( ?string $rule_name = null ): array {
$nodes = array();
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Node
&& ( null === $rule_name || $child->rule_name === $rule_name )
) {
$nodes[] = $child;
}
}
return $nodes;
}
/**
* Get all child tokens of this node.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token[] An array of all matching child tokens.
*/
public function get_child_tokens( ?int $token_id = null ): array {
$tokens = array();
foreach ( $this->children as $child ) {
if (
$child instanceof WP_Parser_Token
&& ( null === $token_id || $child->id === $token_id )
) {
$tokens[] = $child;
}
}
return $tokens;
}
/**
* Get all descendants of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
*
* @return array<WP_Parser_Node|WP_Parser_Token> An array of all descendant nodes and tokens of this node.
*/
public function get_descendants(): array {
$descendants = array();
foreach ( $this->children as $child ) {
if ( $child instanceof WP_Parser_Node ) {
$descendants[] = $child;
$descendants = array_merge( $descendants, $child->get_descendants() );
} else {
$descendants[] = $child;
}
}
return $descendants;
}
/**
* Get all descendant nodes of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
* All matching nodes are collected during the traversal.
*
* @param string|null $rule_name Optional. A node rule name to check for.
* @return WP_Parser_Node[] An array of all matching descendant nodes.
*/
public function get_descendant_nodes( ?string $rule_name = null ): array {
$nodes = array();
foreach ( $this->children as $child ) {
if ( ! $child instanceof WP_Parser_Node ) {
continue;
}
if ( null === $rule_name || $child->rule_name === $rule_name ) {
$nodes[] = $child;
}
$nodes = array_merge( $nodes, $child->get_descendant_nodes( $rule_name ) );
}
return $nodes;
}
/**
* Get all descendant tokens of this node.
*
* The descendants are collected using a depth-first pre-order NLR traversal.
* This produces a natural ordering that corresponds to the original input.
* All matching tokens are collected during the traversal.
*
* @param int|null $token_id Optional. A token ID to check for.
* @return WP_Parser_Token[] An array of all matching descendant tokens.
*/
public function get_descendant_tokens( ?int $token_id = null ): array {
$tokens = array();
foreach ( $this->children as $child ) {
if ( $child instanceof WP_Parser_Token ) {
if ( null === $token_id || $child->id === $token_id ) {
$tokens[] = $child;
}
} else {
$tokens = array_merge( $tokens, $child->get_descendant_tokens( $token_id ) );
}
}
return $tokens;
}
/**
* Get the byte offset in the input string where this node begins.
*
* @return int The byte offset in the input string where this node begins.
*/
public function get_start(): int {
return $this->get_first_descendant_token()->start;
}
/**
* Get the byte length of this node in the input string.
*
* @return int The byte length of this node in the input string.
*/
public function get_length(): int {
$tokens = $this->get_descendant_tokens();
$first_token = $tokens[0];
$last_token = $tokens[ count( $tokens ) - 1 ];
return $last_token->start + $last_token->length - $first_token->start;
}
}
@@ -0,0 +1,77 @@
<?php
/**
* A token, representing a leaf in the parse tree.
*
* This class represents a token that is consumed and recognized by WP_Parser.
* In a parse tree, a token represent a leaf, that is, a node without children.
* It is a simple generic container for a token ID and value, that can be used
* as a base class and extended for specific use cases.
*/
class WP_Parser_Token {
/**
* Token ID represented as an integer constant.
*
* @var int $id
*/
public $id;
/**
* Byte offset in the input where the token begins.
*
* @var int
*/
public $start;
/**
* Byte length of the token in the input.
*
* @var int
*/
public $length;
/**
* Input bytes from which the token was parsed.
*
* @var string
*/
protected $input;
/**
* Constructor.
*
* @param int $id Token type.
* @param int $start Byte offset in the input where the token begins.
* @param int $length Byte length of the token in the input.
* @param string $input Input bytes from which the token was parsed.
*/
public function __construct(
int $id,
int $start,
int $length,
string $input
) {
$this->id = $id;
$this->start = $start;
$this->length = $length;
$this->input = $input;
}
/**
* Get the raw bytes of the token from the input.
*
* @return string The token bytes.
*/
public function get_bytes(): string {
return substr( $this->input, $this->start, $this->length );
}
/**
* Get the real unquoted value of the token.
*
* @return string The token value.
*/
public function get_value(): string {
return $this->get_bytes();
}
}
@@ -0,0 +1,124 @@
<?php
/**
* A recursive descent parser.
*
* This is a dynamic recursive descent parser that can parse LL grammars.
*
* @TODO: Add a detailed description and list the properties that a grammar must
* satisfy in order to be supported by this parser (e.g., no left recursion).
*/
class WP_Parser {
protected $grammar;
protected $tokens;
protected $position;
public function __construct( WP_Parser_Grammar $grammar, array $tokens ) {
$this->grammar = $grammar;
$this->tokens = $tokens;
$this->position = 0;
}
public function parse() {
// @TODO: Make the starting rule lookup non-grammar-specific.
$query_rule_id = $this->grammar->get_rule_id( 'query' );
$ast = $this->parse_recursive( $query_rule_id );
return false === $ast ? null : $ast;
}
private function parse_recursive( $rule_id ) {
$is_terminal = $rule_id <= $this->grammar->highest_terminal_id;
if ( $is_terminal ) {
if ( $this->position >= count( $this->tokens ) ) {
return false;
}
if ( WP_Parser_Grammar::EMPTY_RULE_ID === $rule_id ) {
return true;
}
if ( $this->tokens[ $this->position ]->id === $rule_id ) {
++$this->position;
return $this->tokens[ $this->position - 1 ];
}
return false;
}
$branches = $this->grammar->rules[ $rule_id ];
if ( ! count( $branches ) ) {
return false;
}
// Bale out from processing the current branch if none of its rules can
// possibly match the current token.
if ( isset( $this->grammar->lookahead_is_match_possible[ $rule_id ] ) ) {
$token_id = $this->tokens[ $this->position ]->id;
if (
! isset( $this->grammar->lookahead_is_match_possible[ $rule_id ][ $token_id ] ) &&
! isset( $this->grammar->lookahead_is_match_possible[ $rule_id ][ WP_Parser_Grammar::EMPTY_RULE_ID ] )
) {
return false;
}
}
$rule_name = $this->grammar->rule_names[ $rule_id ];
$starting_position = $this->position;
foreach ( $branches as $branch ) {
$this->position = $starting_position;
$node = new WP_Parser_Node( $rule_id, $rule_name );
$branch_matches = true;
foreach ( $branch as $subrule_id ) {
$subnode = $this->parse_recursive( $subrule_id );
if ( false === $subnode ) {
$branch_matches = false;
break;
} elseif ( true === $subnode ) {
/*
* The subrule was matched without actually matching a token.
* This means a special empty "ε" (epsilon) rule was matched.
* An "ε" rule in a grammar matches an empty input of 0 bytes.
* It is used to represent optional grammar productions.
*/
continue;
} elseif ( is_array( $subnode ) && 0 === count( $subnode ) ) {
continue;
}
if ( is_array( $subnode ) && ! count( $subnode ) ) {
continue;
}
if ( isset( $this->grammar->fragment_ids[ $subrule_id ] ) ) {
$node->merge_fragment( $subnode );
} else {
$node->append_child( $subnode );
}
}
// Negative lookahead for INTO after a valid SELECT statement.
// If we match a SELECT statement, but there is an INTO keyword after it,
// we're in the wrong branch and need to leave matching to a later rule.
// @TODO: Extract this to the "WP_MySQL_Parser" class, or add support
// for right-associative rules, which could solve this.
// See: https://github.com/mysql/mysql-workbench/blob/8.0.38/library/parsers/grammars/MySQLParser.g4#L994
// See: https://github.com/antlr/antlr4/issues/488
$la = $this->tokens[ $this->position ] ?? null;
if ( $la && 'selectStatement' === $rule_name && WP_MySQL_Lexer::INTO_SYMBOL === $la->id ) {
$branch_matches = false;
}
if ( true === $branch_matches ) {
break;
}
}
if ( ! $branch_matches ) {
$this->position = $starting_position;
return false;
}
if ( ! $node->has_child() ) {
return true;
}
return $node;
}
}