diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php
index 4bfbd06261..656be8196e 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php
@@ -12,6 +12,7 @@
use WordPressdotorg\Plugin_Directory\Tools;
use WordPressdotorg\Plugin_Directory\Tools\Filesystem;
use WordPressdotorg\Plugin_Directory\Tools\SVN;
+use WordPressdotorg\Plugin_Directory\Tools\Tokenisation_Helpers;
use WordPressdotorg\Plugin_Directory\Zip\Builder;
/**
@@ -497,6 +498,9 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk
delete_post_meta( $plugin->ID, 'dashboard_widget_name' );
foreach ( $dashboard_widgets as $widget_name ) {
+ if ( '' === $widget_name ) {
+ continue;
+ }
add_post_meta( $plugin->ID, 'dashboard_widget_name', $widget_name, false );
}
} else {
@@ -1190,30 +1194,23 @@ static function find_blocks_in_file( $filename ) {
}
if ( 'php' === $ext ) {
- // Parse a php-style register_block_type() call.
- // Again this assumes literal strings, and only parses the name and title.
+ // Parse register_block_type() calls and `new WP_Block_Type()` constructor calls.
+ // Block names must be literal strings of the form "namespace/name"; the optional
+ // 'title' entry inside the second-arg options array is captured when present.
$contents = file_get_contents( $filename );
-
- // Search out register_block_type() calls.
- if ( $contents && preg_match_all( "#register_block_type\s*[(]\s*['\"]([-\w]+/[-\w]+)['\"](?!\s*[.])#ms", $contents, $matches, PREG_SET_ORDER ) ) {
- foreach ( $matches as $match ) {
- $blocks[] = (object) [
- 'name' => $match[1],
- 'title' => null,
- ];
- }
- }
-
- // Search out WP_Block_Type() instances.
- if ( $contents && preg_match_all( "#new\s+WP_Block_Type\s*[(]\s*['\"]([-\w]+\/[-\w]+)['\"](?!\s*[.])(\s*,[^;]{0,500}['\"]title['\"]\s*=>\s*['\"]([^'\"]+)['\"](?!\s*[.]))?#ms", $contents, $matches, PREG_SET_ORDER ) ) {
- foreach ( $matches as $match ) {
- $blocks[] = (object) [
- 'name' => $match[1],
- 'title' => $match[3] ?? null,
- ];
+ if ( $contents ) {
+ foreach ( array( 'register_block_type', 'new WP_Block_Type' ) as $needle ) {
+ foreach ( Tokenisation_Helpers::find_function_call_first_arg_and_array_value( $contents, $needle, 1, 'title' ) as $name => $title ) {
+ if ( ! preg_match( '#^[-\w]+/[-\w]+$#', $name ) ) {
+ continue;
+ }
+ $blocks[] = (object) array(
+ 'name' => $name,
+ 'title' => $title,
+ );
+ }
}
}
-
}
if ( 'block.json' === basename( $filename ) ) {
@@ -1259,8 +1256,8 @@ function( $is_valid, $prop ) use ( $error ) {
* Look for wp_add_dashboard_widget() calls within a single PHP file.
*
* The second argument is the widget label, often wrapped in __(), _x(),
- * esc_html__(), etc. We extract the first quoted string literal from
- * inside the second argument.
+ * esc_html__(), etc. The first literal-string value reachable through any
+ * such wrapping call is returned.
*
* @param string $filename Pathname of the file.
* @return string[] List of widget label strings.
@@ -1275,24 +1272,7 @@ public static function find_dashboard_widgets_in_file( $filename ) {
return array();
}
- $widgets = array();
-
- // Match wp_add_dashboard_widget( , ).
- if ( preg_match_all(
- '#wp_add_dashboard_widget\s*\(\s*[^,]{1,200},\s*([^;]{1,500}?)(?:,|\))#ms',
- $contents,
- $matches,
- PREG_SET_ORDER
- ) ) {
- foreach ( $matches as $match ) {
- // Pull the first quoted string out of the second argument.
- if ( preg_match( '#[\'"]([^\'"]+)[\'"]#', $match[1], $title_match ) ) {
- $widgets[] = $title_match[1];
- }
- }
- }
-
- return array_unique( $widgets );
+ return array_unique( Tokenisation_Helpers::find_function_call_arg_strings( $contents, 'wp_add_dashboard_widget', 1 ) );
}
/**
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Tokenisation_Helpers_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Tokenisation_Helpers_Test.php
new file mode 100644
index 0000000000..785d95e7b1
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Tokenisation_Helpers_Test.php
@@ -0,0 +1,400 @@
+assertSame(
+ array( 'Plain' ),
+ $this->find_args( "wp_add_dashboard_widget( 'id', 'Plain', 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_translation_wrapper_double_underscore() {
+ $this->assertSame(
+ array( 'Translated' ),
+ $this->find_args( "wp_add_dashboard_widget( 'id', __( 'Translated', 'td' ), 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_translation_wrapper_x() {
+ $this->assertSame(
+ array( 'Contextual' ),
+ $this->find_args( "wp_add_dashboard_widget( 'id', _x( 'Contextual', 'ctx', 'td' ), 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_translation_wrapper_esc_html() {
+ $this->assertSame(
+ array( 'Escaped' ),
+ $this->find_args( "wp_add_dashboard_widget( 'id', esc_html__( 'Escaped', 'td' ), 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_double_quoted_string() {
+ $this->assertSame(
+ array( 'Double' ),
+ $this->find_args( 'wp_add_dashboard_widget( "id", "Double", "cb" );', 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_escaped_single_quote_in_label() {
+ $this->assertSame(
+ array( "Bob's Widget" ),
+ $this->find_args( "wp_add_dashboard_widget( 'id', __( 'Bob\\'s Widget', 'td' ), 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_class_constant_id_with_inline_doc_comment() {
+ // Common pattern: class constant for the ID, doc comment between args, literal label.
+ $src = <<<'PHP'
+wp_add_dashboard_widget(
+ My_Widget_Class::DASHBOARD_WIDGET_ID,
+ /** This is a comment */
+ 'My Widget',
+ array( $instance, 'render' )
+);
+PHP;
+ $this->assertSame(
+ array( 'My Widget' ),
+ $this->find_args( $src, 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_multiline_call() {
+ $src = "wp_add_dashboard_widget(\n\t'id',\n\t__( 'Multi', 'td' ),\n\t'cb'\n);";
+ $this->assertSame( array( 'Multi' ), $this->find_args( $src, 'wp_add_dashboard_widget', 1 ) );
+ }
+
+ /**
+ * False-positive prevention: comments, strings, method calls, declarations.
+ */
+ public function test_call_inside_line_comment_is_ignored() {
+ $this->assertSame(
+ array(),
+ $this->find_args( "// wp_add_dashboard_widget( 'id', 'X', 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_call_inside_block_comment_is_ignored() {
+ $this->assertSame(
+ array(),
+ $this->find_args( "/* wp_add_dashboard_widget( 'id', 'X', 'cb' ); */", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_call_inside_string_literal_is_ignored() {
+ $this->assertSame(
+ array(),
+ $this->find_args( "\$x = \"wp_add_dashboard_widget( 'id', 'X', 'cb' );\";", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_method_call_is_ignored() {
+ $this->assertSame(
+ array(),
+ $this->find_args( "\$obj->wp_add_dashboard_widget( 'id', 'X', 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_static_method_call_is_ignored() {
+ $this->assertSame(
+ array(),
+ $this->find_args( "Foo::wp_add_dashboard_widget( 'id', 'X', 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_function_declaration_is_ignored() {
+ $this->assertSame(
+ array(),
+ $this->find_args( 'function wp_add_dashboard_widget( $id, $name, $cb ) {}', 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_function_exists_argument_is_ignored() {
+ $this->assertSame(
+ array(),
+ $this->find_args( "if ( function_exists( 'wp_add_dashboard_widget' ) ) {}", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ /**
+ * Calls without a literal at the target position still count: they yield
+ * an empty string so callers can detect that the function was used (and
+ * e.g. apply a section term) even when the label is not parseable.
+ */
+ public function test_arg_with_only_variable_yields_empty_string() {
+ $this->assertSame(
+ array( '' ),
+ $this->find_args( "wp_add_dashboard_widget( 'id', \$label, 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_arg_with_only_class_constant_yields_empty_string() {
+ $this->assertSame(
+ array( '' ),
+ $this->find_args( "wp_add_dashboard_widget( 'id', Foo::LABEL, 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ /**
+ * Block-registration calls: register_block_type, new WP_Block_Type, namespaced.
+ */
+ public function test_register_block_type_literal_name() {
+ $this->assertSame(
+ array( 'my-plugin/foo' ),
+ $this->find_args( "register_block_type( 'my-plugin/foo' );", 'register_block_type', 0 )
+ );
+ }
+
+ public function test_new_wp_block_type_constructor() {
+ $this->assertSame(
+ array( 'my-plugin/baz' ),
+ $this->find_args( "new WP_Block_Type( 'my-plugin/baz' );", 'new WP_Block_Type', 0 )
+ );
+ }
+
+ public function test_register_block_type_with_leading_backslash() {
+ // Plugins occasionally call the global function via `\register_block_type(...)`.
+ $this->assertSame(
+ array( 'my-plugin/leading-slash' ),
+ $this->find_args( "\\register_block_type( 'my-plugin/leading-slash' );", 'register_block_type', 0 )
+ );
+ }
+
+ /**
+ * Title-style metadata via find_function_call_first_arg_and_array_value().
+ */
+ public function test_array_value_long_array_form() {
+ $this->assertSame(
+ array( 'my-plugin/foo' => 'Foo Title' ),
+ $this->find_arg_and_array(
+ "register_block_type( 'my-plugin/foo', array( 'title' => 'Foo Title' ) );",
+ 'register_block_type',
+ 1,
+ 'title'
+ )
+ );
+ }
+
+ public function test_array_value_short_array_form() {
+ $this->assertSame(
+ array( 'my-plugin/bar' => 'Bar Title' ),
+ $this->find_arg_and_array(
+ "register_block_type( 'my-plugin/bar', [ 'title' => 'Bar Title' ] );",
+ 'register_block_type',
+ 1,
+ 'title'
+ )
+ );
+ }
+
+ public function test_array_value_with_translation_wrapper() {
+ $this->assertSame(
+ array( 'my-plugin/wrap' => 'Translated Title' ),
+ $this->find_arg_and_array(
+ "new WP_Block_Type( 'my-plugin/wrap', array( 'title' => __( 'Translated Title', 'td' ) ) );",
+ 'new WP_Block_Type',
+ 1,
+ 'title'
+ )
+ );
+ }
+
+ public function test_array_value_with_other_keys_present() {
+ $this->assertSame(
+ array( 'my-plugin/multi' => 'Real Title' ),
+ $this->find_arg_and_array(
+ "register_block_type( 'my-plugin/multi', array( 'category' => 'widgets', 'title' => 'Real Title', 'icon' => 'star' ) );",
+ 'register_block_type',
+ 1,
+ 'title'
+ )
+ );
+ }
+
+ public function test_array_value_missing_key_yields_null() {
+ $this->assertSame(
+ array( 'my-plugin/no-title' => null ),
+ $this->find_arg_and_array(
+ "register_block_type( 'my-plugin/no-title', array( 'category' => 'widgets' ) );",
+ 'register_block_type',
+ 1,
+ 'title'
+ )
+ );
+ }
+
+ public function test_array_value_with_no_options_array_yields_null() {
+ $this->assertSame(
+ array( 'my-plugin/bare' => null ),
+ $this->find_arg_and_array(
+ "register_block_type( 'my-plugin/bare' );",
+ 'register_block_type',
+ 1,
+ 'title'
+ )
+ );
+ }
+
+ public function test_array_value_with_variable_value_yields_null() {
+ $this->assertSame(
+ array( 'my-plugin/dyn' => null ),
+ $this->find_arg_and_array(
+ "register_block_type( 'my-plugin/dyn', array( 'title' => \$dynamic ) );",
+ 'register_block_type',
+ 1,
+ 'title'
+ )
+ );
+ }
+
+ public function test_array_value_skips_calls_with_non_literal_first_arg() {
+ // First arg must be literal for this method to emit an entry.
+ $this->assertSame(
+ array(),
+ $this->find_arg_and_array(
+ "register_block_type( \$name, array( 'title' => 'X' ) );",
+ 'register_block_type',
+ 1,
+ 'title'
+ )
+ );
+ }
+
+ /**
+ * Shortcut (to reduce complexity): a concatenated literal-plus-expression is
+ * captured as the leading literal alone. We do not detect that the value is
+ * actually composed of multiple parts. Consumers that require a clean literal
+ * must validate the captured value (e.g. block detection requires `\w+/\w+`).
+ */
+ public function test_shortcut_concatenated_literal_returns_leading_part() {
+ $this->assertSame(
+ array( 'prefix-' ),
+ $this->find_args( "register_block_type( 'prefix-' . \$name );", 'register_block_type', 0 )
+ );
+ }
+
+ /**
+ * Shortcut (to reduce complexity): only `\register_block_type` (T_NAME_FULLY_QUALIFIED
+ * with a leading backslash) is treated as the global function. Calls inside an
+ * arbitrary namespace, like `Foo\Bar\register_block_type(...)`, are NOT matched
+ * — they are assumed to be unrelated functions that just happen to share the name.
+ */
+ public function test_shortcut_namespaced_call_is_not_matched() {
+ $this->assertSame(
+ array(),
+ $this->find_args( "Foo\\Bar\\register_block_type( 'ns/inside' );", 'register_block_type', 0 )
+ );
+ }
+
+ /**
+ * Shortcut (to reduce complexity): the helper takes the FIRST literal-string
+ * reachable inside the target arg position, regardless of how it nests. A call
+ * that wraps something other than a translation function (e.g. a method call
+ * that happens to take a string literal) will still produce a value — we do
+ * not validate that the surrounding wrapper is a known i18n helper.
+ */
+ public function test_shortcut_arbitrary_wrapping_call_still_captures_inner_literal() {
+ $this->assertSame(
+ array( 'Inner' ),
+ $this->find_args( "wp_add_dashboard_widget( 'id', \$obj->method( 'Inner' ), 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ /**
+ * Shortcut (to reduce complexity): when the target arg is an array literal,
+ * the helper's positional-string method returns the first inner string it
+ * finds — typically a key, not the value. Real callers don't pass arrays
+ * for the labels we extract; for the registration pattern (name + options
+ * array), use find_function_call_first_arg_and_array_value() instead.
+ */
+ public function test_shortcut_array_arg_captures_first_inner_string() {
+ $this->assertSame(
+ array( 'title' ),
+ $this->find_args( "register_block_type( array( 'title' => 'Block Title' ) );", 'register_block_type', 0 )
+ );
+ }
+
+ /**
+ * Multiple matches in one source.
+ */
+ public function test_multiple_calls_in_one_file() {
+ // Each call yields an entry, even when the label arg has no literal —
+ // callers can still detect the function's presence in such cases.
+ $src = "wp_add_dashboard_widget( 'a', 'A', 'cb' );\n"
+ . "wp_add_dashboard_widget( 'b', __( 'B', 'td' ), 'cb' );\n"
+ . "wp_add_dashboard_widget( 'c', \$variable, 'cb' );\n"
+ . "wp_add_dashboard_widget( 'd', 'D', 'cb' );";
+ $this->assertSame(
+ array( 'A', 'B', '', 'D' ),
+ $this->find_args( $src, 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_multiple_block_registrations_with_titles() {
+ $src = "register_block_type( 'p/a', array( 'title' => 'A' ) );\n"
+ . "register_block_type( 'p/b' );\n"
+ . "register_block_type( 'p/c', array( 'title' => __( 'C', 'td' ) ) );";
+ $this->assertSame(
+ array(
+ 'p/a' => 'A',
+ 'p/b' => null,
+ 'p/c' => 'C',
+ ),
+ $this->find_arg_and_array( $src, 'register_block_type', 1, 'title' )
+ );
+ }
+
+ public function test_returns_empty_for_no_matches() {
+ $this->assertSame(
+ array(),
+ $this->find_args( "do_something_else( 'foo' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_invalid_php_returns_empty_array() {
+ $this->assertSame(
+ array(),
+ Tokenisation_Helpers::find_function_call_arg_strings( 'this is not php', 'wp_add_dashboard_widget', 1 )
+ );
+ }
+
+ public function test_function_name_match_is_case_insensitive() {
+ // PHP function names are case-insensitive; mirror that.
+ $this->assertSame(
+ array( 'X' ),
+ $this->find_args( "WP_Add_Dashboard_Widget( 'id', 'X', 'cb' );", 'wp_add_dashboard_widget', 1 )
+ );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-tokenisation-helpers.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-tokenisation-helpers.php
new file mode 100644
index 0000000000..fc917d0899
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-tokenisation-helpers.php
@@ -0,0 +1,274 @@
+ value_at_$array_key ]`, where the value is the
+ * literal-string entry at `$array_key` inside the inline `array(...)`/`[...]`
+ * literal at `$array_arg_index`. The value is null when the arg is not an
+ * inline array, the key is missing, or the matched value is not a literal.
+ *
+ * Use this for the "registration call" pattern where the first arg is the
+ * identifier and a second-arg options array carries inline metadata, e.g.
+ * `register_block_type( 'foo/bar', array( 'title' => 'Foo' ) )`.
+ *
+ * @param string $contents PHP source code.
+ * @param string $function_name Function name to match. Prefix with `'new '`
+ * for a constructor (e.g. `'new WP_Block_Type'`).
+ * @param int $array_arg_index Zero-based index of the array argument.
+ * @param string $array_key Key to look up inside the array literal.
+ * @return array
+ */
+ public static function find_function_call_first_arg_and_array_value( $contents, $function_name, $array_arg_index, $array_key ) {
+ $results = array();
+ foreach ( self::walk_calls( $contents, $function_name ) as $args ) {
+ $name = self::first_literal( $args[0] ?? array() );
+ if ( null === $name || '' === $name ) {
+ continue;
+ }
+ $results[ $name ] = self::array_string_value( $args[ $array_arg_index ] ?? array(), $array_key );
+ }
+ return $results;
+ }
+
+ /**
+ * Walk PHP tokens for calls to `$function_name` and yield each call's
+ * arg-list tokens, split into per-arg slices at top-level commas.
+ *
+ * @return array[] One entry per matched call: [ arg0_tokens, arg1_tokens, ... ].
+ */
+ private static function walk_calls( $contents, $function_name ) {
+ $tokens = @token_get_all( $contents );
+ if ( ! $tokens ) {
+ return array();
+ }
+
+ $is_new = str_starts_with( $function_name, 'new ' );
+ $needle = $is_new ? substr( $function_name, 4 ) : $function_name;
+ $global_form = '\\' . $needle;
+ $skip = array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT );
+ $out = array();
+ $count = count( $tokens );
+
+ for ( $i = 0; $i < $count; $i++ ) {
+ $tok = $tokens[ $i ];
+ if ( ! is_array( $tok ) ) {
+ continue;
+ }
+ $matches_simple = ( T_STRING === $tok[0] && 0 === strcasecmp( $tok[1], $needle ) );
+ $matches_global = ( T_NAME_FULLY_QUALIFIED === $tok[0] && 0 === strcasecmp( $tok[1], $global_form ) );
+ if ( ! $matches_simple && ! $matches_global ) {
+ continue;
+ }
+
+ // Skip method/property access, function declarations, and constructor mismatches.
+ $prev_id = null;
+ for ( $p = $i - 1; $p >= 0; $p-- ) {
+ $pt = $tokens[ $p ];
+ if ( is_array( $pt ) && in_array( $pt[0], $skip, true ) ) {
+ continue;
+ }
+ $prev_id = is_array( $pt ) ? $pt[0] : null;
+ break;
+ }
+ if ( in_array( $prev_id, array( T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NULLSAFE_OBJECT_OPERATOR ), true ) ) {
+ continue;
+ }
+ if ( $is_new ? T_NEW !== $prev_id : T_NEW === $prev_id ) {
+ continue;
+ }
+
+ // Find the opening paren of the call.
+ $j = $i + 1;
+ while ( $j < $count && is_array( $tokens[ $j ] ) && in_array( $tokens[ $j ][0], $skip, true ) ) {
+ $j++;
+ }
+ if ( $j >= $count || '(' !== $tokens[ $j ] ) {
+ continue;
+ }
+
+ // Collect arg-list tokens, splitting at top-level commas.
+ $args = array();
+ $cur = array();
+ $depth = 1;
+ for ( $k = $j + 1; $k < $count; $k++ ) {
+ $t = $tokens[ $k ];
+ if ( is_array( $t ) ) {
+ if ( in_array( $t[0], $skip, true ) ) {
+ continue;
+ }
+ $cur[] = $t;
+ continue;
+ }
+ if ( '(' === $t || '[' === $t || '{' === $t ) {
+ $depth++;
+ $cur[] = $t;
+ continue;
+ }
+ if ( ')' === $t || ']' === $t || '}' === $t ) {
+ $depth--;
+ if ( 0 === $depth ) {
+ if ( $cur ) {
+ $args[] = $cur;
+ }
+ break;
+ }
+ $cur[] = $t;
+ continue;
+ }
+ if ( ',' === $t && 1 === $depth ) {
+ $args[] = $cur;
+ $cur = array();
+ continue;
+ }
+ $cur[] = $t;
+ }
+ $out[] = $args;
+ $i = $j;
+ }
+ return $out;
+ }
+
+ /**
+ * Return the unescaped value of the first `T_CONSTANT_ENCAPSED_STRING` in
+ * the given token list, regardless of nesting depth. null if none.
+ */
+ private static function first_literal( array $tokens ) {
+ foreach ( $tokens as $t ) {
+ if ( is_array( $t ) && T_CONSTANT_ENCAPSED_STRING === $t[0] ) {
+ return self::unescape_string_token( $t[1] );
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Inside an arg-token list that forms an `array(...)` or `[...]` literal,
+ * return the literal-string value of the entry whose key equals `$array_key`.
+ * null when the arg is not an inline array, the key is absent, or the value
+ * isn't a literal string (recursing through wrappers like `__()`).
+ */
+ private static function array_string_value( array $tokens, $array_key ) {
+ $inner = self::array_inner_tokens( $tokens );
+ if ( null === $inner ) {
+ return null;
+ }
+
+ $depth = 0;
+ $count = count( $inner );
+ for ( $i = 0; $i < $count; $i++ ) {
+ $t = $inner[ $i ];
+ if ( ! is_array( $t ) ) {
+ if ( '(' === $t || '[' === $t || '{' === $t ) {
+ $depth++;
+ continue;
+ }
+ if ( ')' === $t || ']' === $t || '}' === $t ) {
+ $depth--;
+ continue;
+ }
+ continue;
+ }
+ if ( 0 !== $depth || T_CONSTANT_ENCAPSED_STRING !== $t[0] ) {
+ continue;
+ }
+ if ( self::unescape_string_token( $t[1] ) !== $array_key ) {
+ continue;
+ }
+
+ // Found the key; the next significant token must be `=>`.
+ for ( $j = $i + 1; $j < $count; $j++ ) {
+ $u = $inner[ $j ];
+ if ( is_array( $u ) && T_DOUBLE_ARROW === $u[0] ) {
+ // Capture the value tokens until the next top-level comma.
+ $value_tokens = array();
+ $vd = 0;
+ for ( $k = $j + 1; $k < $count; $k++ ) {
+ $v = $inner[ $k ];
+ if ( ! is_array( $v ) ) {
+ if ( '(' === $v || '[' === $v || '{' === $v ) {
+ $vd++;
+ } elseif ( ')' === $v || ']' === $v || '}' === $v ) {
+ $vd--;
+ } elseif ( ',' === $v && 0 === $vd ) {
+ break;
+ }
+ }
+ $value_tokens[] = $v;
+ }
+ return self::first_literal( $value_tokens );
+ }
+ break;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the inner tokens of an `array(...)` or `[...]` literal, or null
+ * when the given tokens are not such a literal.
+ */
+ private static function array_inner_tokens( array $tokens ) {
+ $count = count( $tokens );
+ if ( $count < 2 ) {
+ return null;
+ }
+ $first = $tokens[0];
+ $last = $tokens[ $count - 1 ];
+ if ( '[' === $first && ']' === $last ) {
+ return array_slice( $tokens, 1, $count - 2 );
+ }
+ if ( $count >= 3 && is_array( $first ) && T_ARRAY === $first[0] && '(' === $tokens[1] && ')' === $last ) {
+ return array_slice( $tokens, 2, $count - 3 );
+ }
+ return null;
+ }
+
+ /**
+ * Decode a PHP string literal token (with surrounding quotes) to its value.
+ */
+ private static function unescape_string_token( $literal ) {
+ if ( strlen( $literal ) < 2 ) {
+ return $literal;
+ }
+ $body = substr( $literal, 1, -1 );
+ if ( "'" === $literal[0] ) {
+ return str_replace( array( "\\'", '\\\\' ), array( "'", '\\' ), $body );
+ }
+ return stripcslashes( $body );
+ }
+}