diff --git a/lib/class-plugin.php b/lib/class-plugin.php index 5fe7f95..30b1e02 100644 --- a/lib/class-plugin.php +++ b/lib/class-plugin.php @@ -216,11 +216,65 @@ public function method_permalink( $link, $post ) { */ public function make_args_safe( $args ) { - array_walk_recursive( $args, array( $this, 'sanitize_argument' ) ); + if ( is_array( $args ) ) { + $args = $this->sanitize_arguments( $args ); + } return apply_filters( 'wp_parser_make_args_safe', $args ); } + /** + * Sanitizes every field of an argument list according to what the field holds. + * + * Only the types of an argument are a type expression. Its name, its default + * value and its description are prose, and are printed without escaping, so + * they go through the content filters whatever they happen to look like. + * + * @param array $args Arguments to make safe. + * + * @return array The arguments, made safe. + */ + protected function sanitize_arguments( array $args ) { + + foreach ( $args as $key => $value ) { + if ( 'type' === $key || 'types' === $key ) { + $args[ $key ] = $this->sanitize_type( $value ); + } elseif ( is_array( $value ) ) { + $args[ $key ] = $this->sanitize_arguments( $value ); + } else { + $args[ $key ] = $this->sanitize_argument( $value ); + } + } + + return $args; + } + + /** + * Sanitizes a type, or a list of them, without destroying the type expression. + * + * The content filters are written for prose, and a type expression isn't + * prose: a fully qualified class name is all namespace separators, which + * `stripslashes_deep()` eats, and a generic type is wrapped in what + * `wp_filter_kses()` reads as an HTML tag and throws away. A type which is + * displayed as written is escaped where it's printed instead. + * + * @param mixed $type A type expression, or a list of them. + * + * @return mixed The type, made safe. + */ + protected function sanitize_type( $type ) { + + if ( is_array( $type ) ) { + return array_map( array( $this, 'sanitize_type' ), $type ); + } + + if ( is_string( $type ) && $this->is_type_expression_safe( $type ) ) { + return $type; + } + + return $this->sanitize_argument( $type ); + } + /** * @param mixed $value * @@ -245,14 +299,120 @@ public function sanitize_argument( &$value ) { return $value; } + /** + * Reports whether a value is a type expression which is safe to display as written. + * + * @param string $value Value to check. + * + * @return bool + */ + protected function is_type_expression_safe( $value ) { + + if ( ! is_string( $value ) ) { + return false; + } + + /* + * Only the characters a DocBlock type expression is written with are + * allowed. That leaves out `/` and `=` entirely, so neither a closing + * tag nor an attribute can be written at all, which is what every markup + * injection needs. + */ + if ( 1 !== preg_match( '~^[A-Za-z0-9_\\\\|&,\'"()\[\]{}<>?:.$\s-]++$~', $value ) ) { + return false; + } + + /* + * A bracket at the very start qualifies nothing, so `hello` is markup + * in front of prose rather than a generic type. A group is the exception: + * a nested expression is written in front of an array suffix, as in + * `(int|string)[]`. + */ + if ( false !== strpos( '<[{', $value[0] ) ) { + return false; + } + + /* + * Whether the whole value is a type expression is decided by the same + * scanner which decides where a tag's type expression ends, so a type + * the exporter preserves can't be one this destroys. A bracket which is + * never closed reads as a start tag which swallows everything after it + * up to the next `>`, wherever that turns out to be, and whitespace + * anywhere but where a type expression breaks means it's prose. + */ + $scan = scan_docblock_tag_content( $value ); + if ( ! $scan['scannable'] || ! $scan['balanced'] || $value !== $scan['type'] ) { + return false; + } + + /* + * An element whose content isn't parsed as markup can execute or + * swallow everything after it even with no attributes and no closing + * tag, so a type which reads as one of those is never displayed as + * written. A class actually named `Script` is the price of that. + * + * `object` and `embed` are deliberately not on this list: `array` + * is an everyday type, and an attribute-less `` or `` has + * nothing to load, since the `=` and `/` their exploits need are already + * rejected above. + */ + return 1 !== preg_match( + '~<\s*(?:script|style|iframe|xmp|textarea|title|svg|math|template' + . '|plaintext|noembed|noframes|noscript|listing|select)\b~i', + $value + ); + } + /** * Replace separators with a more readable version * - * @param string $type Variable type + * Only the separators between the top-level members of a union are replaced. + * A union nested inside brackets, as in `list`, is part of a + * single type and is left untouched. + * + * This is the point at which a type expression becomes markup, so everything + * in it but the separator this adds is escaped: the angle brackets of a + * generic type read as a start tag otherwise, and a browser swallows the type + * along with them. Escaping here rather than where the type is printed keeps + * the separator markup intact, since it's added after the escaping. * - * @return string + * @param string|string[] $type Variable type, or the list of a tag's types. + * + * @return string|string[] The type as display-ready HTML. */ public function humanize_separator( $type ) { - return str_replace( '|', '' . _x( ' or ', 'separator', 'wp-parser' ) . '', $type ); + + /* + * The `wp_parser_return_type` filter is passed the whole list of a + * return tag's types rather than a single type expression, so every + * one of them is humanized on its own. + */ + if ( is_array( $type ) ) { + return array_map( array( $this, 'humanize_separator' ), $type ); + } + + if ( ! is_string( $type ) ) { + return $type; + } + + $separator = '' . _x( ' or ', 'separator', 'wp-parser' ) . ''; + $scan = scan_docblock_type_syntax( $type ); + + /* + * A bracket which is never closed isn't a type expression, so there is + * no telling which separator is nested inside a single type and which + * one separates two of them. Every separator is replaced in that case, + * which is what this did before it knew about brackets at all. The + * escaped expression can't contain a bracket for a separator to hide + * inside of, so replacing them all is safe here. + */ + if ( ! $scan['balanced'] ) { + return str_replace( '|', $separator, esc_html( $type ) ); + } + + return implode( + $separator + , array_map( 'esc_html', split_docblock_type_expression( $type, '|' ) ) + ); } } diff --git a/lib/runner.php b/lib/runner.php index 70e77d9..ecea77f 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -6,6 +6,9 @@ use phpDocumentor\Reflection\ClassReflector; use phpDocumentor\Reflection\ClassReflector\MethodReflector; use phpDocumentor\Reflection\ClassReflector\PropertyReflector; +use phpDocumentor\Reflection\DocBlock\Context; +use phpDocumentor\Reflection\DocBlock\Tag\MethodTag; +use phpDocumentor\Reflection\DocBlock\Type\Collection; use phpDocumentor\Reflection\FunctionReflector; use phpDocumentor\Reflection\FunctionReflector\ArgumentReflector; use phpDocumentor\Reflection\ReflectionAbstract; @@ -233,6 +236,730 @@ function ( $matches ) use ( $replacement_string ) { return $text; } +/** + * Marks a byte which is outside of every bracket and string literal. + */ +const DOCBLOCK_SCAN_TOP_LEVEL = '.'; + +/** + * Marks a byte which is inside a quoted string literal, such as `'a '>', + '(' => ')', + '[' => ']', + '{' => '}', + ); + + /* + * A `(` which directly follows the name of something opens that thing's + * parameter list, as in `callable(int $a): bool`. A `(` which follows + * anything else opens a group holding a single nested expression, as in + * `(int|string)[]`, which is why the two are marked apart. + */ + $identifier_characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_'; + + /* + * The enclosing mark and the bracket which closes the current group are + * tracked alongside each other so that neither has to be recomputed for + * every byte, which is the difference between scanning a long expression + * once and scanning it once per byte. + */ + $enclosing = array(); + $closing = ''; + $mark = DOCBLOCK_SCAN_TOP_LEVEL; + $quote = ''; + $mask = ''; + $calls = array(); + $length = strlen( $expression ); + + for ( $i = 0; $i < $length; $i++ ) { + $character = $expression[ $i ]; + + if ( '' !== $quote ) { + $mask .= DOCBLOCK_SCAN_QUOTED; + + if ( $character === $quote ) { + $quote = ''; + } + + continue; + } + + if ( "'" === $character || '"' === $character ) { + $quote = $character; + $mask .= DOCBLOCK_SCAN_QUOTED; + continue; + } + + if ( isset( $closing_brackets[ $character ] ) ) { + $mask .= $mark; + $enclosing[] = array( $closing, $mark ); + $closing = $closing_brackets[ $character ]; + + if ( '(' !== $character ) { + $mark = DOCBLOCK_SCAN_NESTED; + } else { + $preceding = 0 < $i ? $expression[ $i - 1 ] : ''; + $mark = ( + '' !== $preceding + && ( + 0x80 <= ord( $preceding ) + || false !== strpos( $identifier_characters, $preceding ) + ) + ) + ? DOCBLOCK_SCAN_CALL + : DOCBLOCK_SCAN_GROUPED; + } + + continue; + } + + // An empty closing bracket never matches, so no group is open. + if ( $character === $closing ) { + if ( DOCBLOCK_SCAN_CALL === $mark ) { + $calls[ $i ] = true; + } + + list( $closing, $mark ) = array_pop( $enclosing ); + } + + $mask .= $mark; + } + + return array( + 'mask' => $mask, + 'calls' => $calls, + 'balanced' => '' === $quote && '' === $closing, + ); +} + +/** + * Reports whether whitespace inside brackets sits where a type expression breaks. + * + * A type expression only breaks after one of the delimiters a generic, an array + * shape or a group is written with, or against the bracket which opens or closes + * one. An array shape is regularly written over several lines, which puts a break + * in both of those places. + * + * Whitespace anywhere else in them, as in `Generator`, means the + * brackets are prose rather than a type expression. + * + * @param string $content Tag content or type expression. + * @param int $offset Offset of the whitespace run. + * @param int $run Length of the whitespace run. + * + * @return bool + */ +function is_docblock_type_expression_break( $content, $offset, $run ) { + $before = 0 < $offset ? $content[ $offset - 1 ] : ''; + $after = $offset + $run < strlen( $content ) ? $content[ $offset + $run ] : ''; + + return ( '' !== $before && false !== strpos( ',:|&<{[(', $before ) ) + || ( '' !== $after && false !== strpos( '>}])', $after ) ); +} + +/** + * Splits a type expression on a delimiter outside of brackets. + * + * @param string $type Type expression. + * @param string $delimiter Delimiter on which to split. + * + * @return string[] + */ +function split_docblock_type_expression( $type, $delimiter ) { + $scan = scan_docblock_type_syntax( $type ); + $mask = $scan['mask']; + $parts = array(); + $start = 0; + $offset = 0; + + while ( false !== ( $position = strpos( $type, $delimiter, $offset ) ) ) { + if ( DOCBLOCK_SCAN_TOP_LEVEL === $mask[ $position ] ) { + $parts[] = substr( $type, $start, $position - $start ); + $start = $position + 1; + } + + $offset = $position + 1; + } + + $parts[] = substr( $type, $start ); + + return $parts; +} + +/** + * Splits a DocBlock tag's content into its type expression and the text after it. + * + * @param string $content Tag content. + * + * @return string[] The type expression, followed by the remaining content. + */ +function split_docblock_tag_content( $content ) { + $split = scan_docblock_tag_content( $content ); + + return array( $split['type'], $split['remainder'] ); +} + +/** + * Finds where a DocBlock tag's type expression ends and its text begins. + * + * A type expression may contain whitespace, but only in a few places: inside a + * callable's parameter list, inside a string literal, after one of the + * delimiters a generic, an array shape or a group breaks at, and between a + * callable's parameter list and its return type. Whitespace anywhere else inside + * brackets means the brackets aren't a type expression at all, and nothing + * better can be inferred than the plain split on the first whitespace which the + * legacy dependency made. + * + * Whitespace is matched in Unicode mode so that a non-breaking space separates + * the type from the rest of the content, matching how the tag was parsed + * upstream. Content which isn't valid UTF-8 can't be matched that way at all, + * and is reported as unscannable so that callers can leave the tag as parsed + * rather than rewrite half of it from a split which never happened. + * + * @param string $content Tag content. + * + * @return array { + * @type string $type The type expression. + * @type string $remainder The content which follows the type expression. + * @type bool $scannable Whether the content could be scanned as UTF-8. + * @type bool $balanced Whether every bracket and string literal is closed. + * } + */ +function scan_docblock_tag_content( $content ) { + $scan = scan_docblock_type_syntax( $content ); + + $matched = preg_match_all( '/\s+/Su', $content, $matches, PREG_OFFSET_CAPTURE ); + if ( false === $matched ) { + // The content isn't valid UTF-8; split it bytewise instead. + $parts = split_docblock_tag_content_on_whitespace( $content, '/\s+/' ); + + return array( + 'type' => $parts[0], + 'remainder' => $parts[1], + 'scannable' => false, + 'balanced' => $scan['balanced'], + ); + } + + $whitespace = array(); + foreach ( $matches[0] as $match ) { + $whitespace[ $match[1] ] = strlen( $match[0] ); + } + + $mask = $scan['mask']; + $malformed = false; + $length = strlen( $content ); + + for ( $i = 0; $i < $length; $i++ ) { + if ( ! isset( $whitespace[ $i ] ) ) { + continue; + } + + $run = $whitespace[ $i ]; + $mark = $mask[ $i ]; + + if ( DOCBLOCK_SCAN_TOP_LEVEL === $mark ) { + /* + * A callable's return type is written after its parameter list, as + * in `callable(int $a): bool`, so the whitespace which follows the + * colon is inside the type expression rather than at the end of it. + * A group holds a single nested expression and has no parameter list + * to declare a return type for, so a `):` which closes one, as in + * `(bool): true on success`, is where the prose starts. + */ + if ( + 2 <= $i + && ':' === $content[ $i - 1 ] + && ')' === $content[ $i - 2 ] + && isset( $scan['calls'][ $i - 2 ] ) + ) { + $i += $run - 1; + continue; + } + + return array( + 'type' => substr( $content, 0, $i ), + 'remainder' => substr( $content, $i + $run ), + 'scannable' => true, + 'balanced' => $scan['balanced'], + ); + } + + /* + * Whitespace inside a generic, an array shape or a group only ever sits + * where the expression breaks. Whitespace inside a callable's parameter + * list separates one parameter from the next and may sit anywhere. + */ + if ( + DOCBLOCK_SCAN_CALL !== $mark + && DOCBLOCK_SCAN_QUOTED !== $mark + && ! is_docblock_type_expression_break( $content, $i, $run ) + ) { + $malformed = true; + break; + } + + // Skip past the rest of the whitespace run, which is inside the type. + $i += $run - 1; + } + + if ( $malformed || ! $scan['balanced'] ) { + $parts = split_docblock_tag_content_on_whitespace( $content, '/\s+/u' ); + + return array( + 'type' => $parts[0], + 'remainder' => $parts[1], + 'scannable' => true, + 'balanced' => $scan['balanced'], + ); + } + + return array( + 'type' => $content, + 'remainder' => '', + 'scannable' => true, + 'balanced' => $scan['balanced'], + ); +} + +/** + * Splits a DocBlock tag's content on the first whitespace matched by a pattern. + * + * @param string $content Tag content. + * @param string $pattern Whitespace pattern. + * + * @return string[] The type expression, followed by the remaining content. + */ +function split_docblock_tag_content_on_whitespace( $content, $pattern ) { + $parts = preg_split( $pattern, $content, 2 ); + if ( ! is_array( $parts ) ) { + return array( $content, '' ); + } + + return array( $parts[0], isset( $parts[1] ) ? $parts[1] : '' ); +} + +/** + * Reports whether a type is a plain identifier or fully qualified class name. + * + * A trailing class constant, which may itself be a wildcard such as + * `Base::TYPE_*`, is part of that shape because the legacy dependency resolves + * the class name in front of it. + * + * Anything else, such as an array shape, a callable signature, a literal, a + * hyphenated pseudo-type, or an unbalanced fragment, is beyond the grammar the + * legacy dependency understands and must not be handed to it. + * + * @param string $type Type expression. + * + * @return bool + */ +function is_docblock_type_identifier( $type ) { + $identifier = '[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*'; + + return 1 === preg_match( + '/^\\\\?' . $identifier . '(?:\\\\' . $identifier . ')*' + . '(?:::(?:\*|' . $identifier . '\*?))?$/', + $type + ); +} + +/** + * Reports whether a type is a keyword rather than a class name. + * + * The legacy dependency has its own keyword list, but it predates a number of + * PHPDoc built-ins and would resolve them against the DocBlock's namespace. + * + * @param string $type Type expression. + * + * @return bool + */ +function is_docblock_type_keyword( $type ) { + static $keywords = array( + 'array', + 'bool', + 'boolean', + 'callable', + 'callback', + 'double', + 'false', + 'float', + 'int', + 'integer', + 'iterable', + 'list', + 'mixed', + 'never', + 'null', + 'object', + 'parent', + 'resource', + 'scalar', + 'self', + 'static', + 'string', + 'true', + 'void', + ); + + /* + * The bounds of an integer range, as in `int<0,max>`, are only ever written + * in lower case, while `Max` and `Min` are ordinary class names which have + * to keep resolving against the DocBlock's namespace. + */ + static $lowercase_keywords = array( + 'max', + 'min', + ); + + return in_array( $type, $lowercase_keywords, true ) + || in_array( strtolower( $type ), $keywords, true ); +} + +/** + * The deepest a type expression is scanned before it is left as written. + * + * A DocBlock never nests types anywhere near this deeply, while a pathological + * expression can nest them thousands deep, where each level rescans and copies + * everything inside it. Beyond this depth the remainder is returned as written, + * which is what every level of a deeply nested expression resolves to anyway. + */ +const DOCBLOCK_TYPE_EXPRESSION_MAX_DEPTH = 50; + +/** + * Trims the whitespace from around a type expression. + * + * A DocBlock is written with Unicode whitespace as readily as with ASCII + * whitespace, and the tag was parsed upstream with a Unicode-mode match, so a + * non-breaking space is a separator here as well. What `trim()` leaves behind + * reads as the first character of a class name, which is how `array` + * written with a non-breaking space comes out naming a class of its own. + * + * @param string $type Type expression. + * + * @return string + */ +function trim_docblock_type_expression( $type ) { + $trimmed = trim( $type ); + if ( '' === $trimmed ) { + return ''; + } + + // Only an expression with a non-ASCII byte at an end can need more trimming. + if ( + 0x80 > ord( $trimmed[0] ) + && 0x80 > ord( $trimmed[ strlen( $trimmed ) - 1 ] ) + ) { + return $trimmed; + } + + $untrimmed = preg_replace( '/^\s+|\s+$/u', '', $trimmed ); + + // An expression which isn't valid UTF-8 can't be matched that way at all. + return null === $untrimmed ? $trimmed : $untrimmed; +} + +/** + * Expands aliases in a type expression without losing nested type syntax. + * + * @param string $type Type expression. + * @param Context|null $context DocBlock namespace and alias context. + * @param int $depth How deeply this expression is already nested. + * + * @return string + */ +function expand_docblock_type_expression( $type, ?Context $context, $depth = 0 ) { + $type = trim_docblock_type_expression( $type ); + if ( '' === $type ) { + return ''; + } + + if ( DOCBLOCK_TYPE_EXPRESSION_MAX_DEPTH < $depth ) { + return $type; + } + + // Scanning for a delimiter which isn't there at all costs a copy per level. + if ( false !== strpbrk( $type, '|,&' ) ) { + foreach ( array( '|', ',', '&' ) as $delimiter ) { + $parts = split_docblock_type_expression( $type, $delimiter ); + if ( 1 < count( $parts ) ) { + $expanded_parts = array(); + foreach ( $parts as $part ) { + $expanded_part = expand_docblock_type_expression( $part, $context, $depth + 1 ); + + // Never drop a part: one which can't be expanded is kept as written. + $expanded_parts[] = '' === $expanded_part + ? trim_docblock_type_expression( $part ) + : $expanded_part; + } + + return implode( $delimiter, $expanded_parts ); + } + } + } + + /* + * A nullable type is shorthand for a union with `null`. The legacy + * dependency has no such shorthand and would resolve the `?` as part of the + * class name, so it is peeled off before anything else is looked at. + */ + if ( '?' === $type[0] ) { + return '?' . expand_docblock_type_expression( substr( $type, 1 ), $context, $depth + 1 ); + } + + /* + * Every trailing array suffix is peeled off at once. Recursing once per + * suffix copies the entire expression at each level, which costs quadratic + * time and linear stack depth for a type such as `int[][][]...`. + */ + $length = strlen( $type ); + $base_length = $length; + while ( + $base_length >= 2 + && '[' === $type[ $base_length - 2 ] + && ']' === $type[ $base_length - 1 ] + ) { + $base_length -= 2; + } + + if ( $base_length < $length ) { + return expand_docblock_type_expression( substr( $type, 0, $base_length ), $context, $depth + 1 ) + . substr( $type, $base_length ); + } + + if ( preg_match( '/^([^<]+)<(.*)>$/s', $type, $matches ) ) { + $container = expand_docblock_type_expression( $matches[1], $context, $depth + 1 ); + $arguments = expand_docblock_type_expression( $matches[2], $context, $depth + 1 ); + + return $container . '<' . $arguments . '>'; + } + + /* + * A group which wraps the whole expression, such as `(int|string)`, holds a + * nested expression. The parentheses have to be scanned to tell that group + * apart from an expression which merely starts and ends with one, such as + * `(int)foo(string)`: every byte between the two is inside the group only + * when the first parenthesis is the one the last parenthesis closes. The + * scanner is what decides which parentheses are brackets at all, so a + * parenthesis inside a string literal doesn't open or close anything. + */ + if ( '(' === $type[0] && ')' === $type[ $length - 1 ] ) { + $scan = scan_docblock_type_syntax( $type ); + + if ( $length - 1 === strpos( $scan['mask'], DOCBLOCK_SCAN_TOP_LEVEL, 1 ) ) { + return '(' . expand_docblock_type_expression( substr( $type, 1, -1 ), $context, $depth + 1 ) . ')'; + } + } + + // A class constant is a keyword when the class name in front of it is one. + $name = strstr( $type, '::', true ); + if ( false === $name ) { + $name = $type; + } + + if ( ! is_docblock_type_identifier( $type ) || is_docblock_type_keyword( $name ) ) { + return $type; + } + + $types = new Collection( array( $type ), $context ); + + return isset( $types[0] ) ? $types[0] : ''; +} + +/** + * Recovers a parameter's name from the content which follows its type. + * + * The legacy dependency only recognizes a name written as `$name` or `...$name`, + * so a parameter which is passed by reference loses its name entirely. + * + * @param string $remainder Content which follows a tag's type expression. + * + * @return string[]|null The name and the content after it, or null when the + * content doesn't begin with a name. + */ +function recover_docblock_tag_variable( $remainder ) { + $parts = preg_split( '/\s+/Su', ltrim( $remainder ), 2 ); + if ( ! is_array( $parts ) || ! isset( $parts[0] ) ) { + return null; + } + + $variable = $parts[0]; + + // A parameter passed by reference is named without its ampersand. + if ( '&' === substr( $variable, 0, 1 ) ) { + $variable = substr( $variable, 1 ); + } + + // A variadic parameter is named without its ellipsis. + if ( '...$' === substr( $variable, 0, 4 ) ) { + $variable = substr( $variable, 3 ); + } + + if ( '' === $variable || '$' !== $variable[0] ) { + return null; + } + + return array( $variable, isset( $parts[1] ) ? $parts[1] : '' ); +} + +/** + * Re-derives a tag's export from a bracket-aware split of its content. + * + * The legacy dependency splits the type off of a tag's content at the first + * whitespace, so a type expression which contains whitespace, such as + * `array`, also swallows the variable name and the start of the + * description. The types, the variable name and the description all come out of + * the same split here so that they can't disagree with one another. + * + * @param array $tag_data Exported tag data. + * @param object $tag DocBlock tag. + * @param Context|null $context DocBlock namespace and alias context. + * + * @return array The exported tag data. + */ +function resolve_docblock_tag_type_expression( array $tag_data, $tag, ?Context $context ) { + $content = trim( $tag->getContent() ); + if ( '' === $content ) { + return $tag_data; + } + + $split = scan_docblock_tag_content( $content ); + + /* + * Content which can't be scanned was split in a way the parsed variable + * name and description know nothing about, so rewriting either of them from + * it would leave the two halves of the export describing different splits. + */ + if ( ! $split['scannable'] ) { + return $tag_data; + } + + /* + * A tag whose content begins with a variable has no type expression for the + * legacy dependency to have mis-split, and nothing to re-derive from: its + * export has to match the legacy export byte for byte. + */ + $type = $split['type']; + if ( '' === $type || '$' === $type[0] || '...$' === substr( $type, 0, 4 ) ) { + return $tag_data; + } + + /* + * A parameter which is passed by reference is written as `&$name`, which the + * legacy dependency doesn't recognize as a name: it reads the whole thing as + * the type expression and leaves the parameter unnamed. There is no type + * expression in front of the name to derive anything from, so the name is + * recovered from the content and no type is published for it. + */ + if ( '&$' === substr( $type, 0, 2 ) || '&...$' === substr( $type, 0, 5 ) ) { + if ( ! isset( $tag_data['variable'] ) ) { + return $tag_data; + } + + $recovered = recover_docblock_tag_variable( $content ); + if ( null === $recovered ) { + return $tag_data; + } + + $tag_data['variable'] = $recovered[0]; + $tag_data['types'] = array(); + $tag_data['content'] = preg_replace( + '/[\n\r]+/', + ' ', + format_description( trim( $recovered[1] ) ) + ); + + return $tag_data; + } + + $types = array(); + foreach ( split_docblock_type_expression( $type, '|' ) as $type_part ) { + $expanded_type = expand_docblock_type_expression( $type_part, $context ); + if ( '' !== $expanded_type ) { + $types[] = $expanded_type; + } + } + + if ( ! empty( $types ) ) { + $tag_data['types'] = $types; + } + + $remainder = $split['remainder']; + + // Only a type expression which contains whitespace was split differently. + $rewrite_content = 1 === preg_match( '/\s/Su', $type ); + + if ( + isset( $tag_data['variable'] ) + && ( $rewrite_content || '' === $tag_data['variable'] ) + ) { + $recovered = recover_docblock_tag_variable( $remainder ); + if ( null !== $recovered ) { + list( $tag_data['variable'], $remainder ) = $recovered; + + $rewrite_content = true; + } + } + + if ( $rewrite_content ) { + $tag_data['content'] = preg_replace( + '/[\n\r]+/', + ' ', + format_description( trim( $remainder ) ) + ); + } + + return $tag_data; +} + /** * Exports one reflected DocBlock and its runnable snippet metadata. * @@ -555,6 +1282,22 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), } } } + + /* + * Method tags have a distinct content grammar which may begin with + * `static`. They are also left alone because the legacy dependency + * matches their return type with `[\w|_\\]+`, which can't match `<` at + * all: a generic type in an `@method` tag is already lost by the time + * the tag reaches here, so there is nothing left to recover. + */ + if ( isset( $tag_data['types'] ) && ! $tag instanceof MethodTag ) { + $tag_data = resolve_docblock_tag_type_expression( + $tag_data, + $tag, + $docblock->getContext() + ); + } + $output['tags'][] = $tag_data; } diff --git a/lib/template.php b/lib/template.php index e2a5c0b..a74c88b 100644 --- a/lib/template.php +++ b/lib/template.php @@ -31,7 +31,7 @@ function the_content() { foreach ( $args as $arg ) { $after_content .= '
'; - $after_content .= '

' . implode( '|', $arg['types'] ) . ' ' . $arg['name'] . '

'; + $after_content .= '

' . get_type_list_html( $arg['types'] ) . ' ' . $arg['name'] . '

'; $after_content .= empty( $arg['desc'] ) ? '' : wpautop( $arg['desc'], false ); $after_content .= '
'; } @@ -50,9 +50,30 @@ function the_content() { echo $before_content . $content . $after_content; } +/** + * Renders a list of types as the text of a prototype. + * + * A type expression isn't markup, but it's written with characters which are: + * `list` is read as the text `list` followed by a start tag, + * and everything the type says about itself disappears from the page. Each type + * is escaped so that it's displayed as written. + * + * @param string[] $types The types to render. + * + * @return string The types as display-ready HTML. + */ +function get_type_list_html( $types ) { + return implode( '|', array_map( 'esc_html', (array) $types ) ); +} + /** * Get the current function's return types * + * The types are returned as display-ready HTML: the `wp_parser_return_type` + * filter is passed the types as they were parsed, and is responsible for + * escaping them, which is what the default `humanize_separator()` callback + * does before it decorates the separators between them. + * * @return array */ function get_return_type() { @@ -73,7 +94,11 @@ function get_return_type() { /** * Print the current function's return type * - * @see return_type + * The types are already escaped by the `wp_parser_return_type` filter, and + * aren't escaped again here: the separator between the members of a union is + * markup which that filter added on purpose. + * + * @see get_return_type */ function the_return_type() { echo implode( '|', get_return_type() ); @@ -249,12 +274,13 @@ function get_hook_arguments() { * @return string Prototype HTML */ function get_prototype() { + // Already escaped by the `wp_parser_return_type` filter. @see get_return_type(). $type = get_return_type(); $friendly_args = array(); $args = get_arguments(); foreach ( $args as $arg ) { - $friendly = sprintf( '%s %s', implode( '|', $arg['types'] ), $arg['name'] ); + $friendly = sprintf( '%s %s', get_type_list_html( $arg['types'] ), $arg['name'] ); $friendly .= empty( $arg['default_value'] ) ? '' : ' = ' . $arg['default_value'] . ''; $friendly_args[] = $friendly; @@ -288,7 +314,7 @@ function get_hook_prototype() { $friendly_args = array(); $args = get_hook_arguments(); foreach ( $args as $arg ) { - $friendly = sprintf( '%s %s', implode( '|', $arg['types'] ), $arg['name'] ); + $friendly = sprintf( '%s %s', get_type_list_html( $arg['types'] ), $arg['name'] ); $has_value = ! empty( $arg['value'] ) && 0 !== strpos( $arg['value'], '$' ); $friendly .= $has_value ? ' = ' . $arg['value'] . '' : ''; diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 2762463..195da65 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -22,6 +22,8 @@ * @since 1.5.0 */ +use WordPress\AiClient\Messages\DTO\MessagePart; + /** * This is a function docblock. * @@ -40,6 +42,142 @@ function test_func( $var, $num ) { return true; } +/** + * Tests a nested union type inside a generic. + * + * @param MessagePart|list|MessagePart[] $prompt A prompt. + */ +function test_nested_union_type( $prompt ) { +} + +/** + * Tests a parenthesized union with an array suffix. + * + * @param (int|MessagePart)[] $x Grouped. + */ +function test_grouped_union_type( $x ) { +} + +/** + * Tests a generic type which contains a space after the delimiter. + * + * @param array $map A map. + */ +function test_generic_type_with_space( $map ) { +} + +/** + * Tests a type separated from the variable by a non-breaking space. + * + * @param stringĀ $nb NBSP separated. + */ +function test_nbsp_separated_type( $nb ) { +} + +/** + * Tests an iterable generic type. + * + * @param iterable $it Iterable. + */ +function test_iterable_generic_type( $it ) { +} + +/** + * Tests an integer range return type. + * + * @return int<0,max> Count. + */ +function test_int_range_return_type() { +} + +/** + * Tests an intersection type. + * + * @param MessagePart&Countable $both Intersect. + */ +function test_intersection_type( $both ) { +} + +/** + * Tests a quoted string literal type which contains a bracket. + * + * @param 'a with gt + */ +function test_quoted_literal_type( $x ) { +} + +/** + * Tests an unclosed generic type followed by prose. + * + * @param arrayprop blah + */ +function test_unclosed_generic_type( $x ) { +} + +/** + * Tests a generic which is missing a comma and balances at the last byte. + * + * @return Generator + */ +function test_unbalanced_generic_return_type() { +} + +/** + * Tests a callable type which declares a return type. + * + * @param callable(int $a, string $b): bool $cb Desc. + */ +function test_callable_return_type( $cb ) { +} + +/** + * Tests a parameter which is passed by reference. + * + * @param array &$arr By ref. + */ +function test_by_reference_param( &$arr ) { +} + +/** + * Tests a return type which is one leading paren group full of prose. + * + * @return (mixed depends on context) + */ +function test_leading_group_prose_return_type() { +} + +/** + * Tests a leading paren group which is followed by a colon. + * + * @return (bool): true on success + */ +function test_leading_group_colon_return_type() { +} + +/** + * Tests a callable type with an empty parameter list. + * + * @param callable(): bool $cb Empty. + */ +function test_empty_callable_param( $cb ) { +} + +/** + * Tests an untyped parameter which is passed by reference. + * + * @param &$arr By ref desc. + */ +function test_untyped_by_reference_param( &$arr ) { +} + +/** + * Tests an untyped variadic parameter which is passed by reference. + * + * @param &...$rest The rest. + */ +function test_untyped_variadic_by_reference_param( &...$rest ) { +} + /** * This is a class docblock. * @@ -72,6 +210,20 @@ class Test_Class { */ public $a_string; + /** + * This is a callable property. + * + * @var callable(string): string + */ + public $a_callback; + + /** + * This is a property whose type expression begins with a variable. + * + * @var $map{int, string} Some desc + */ + public $a_shape; + /** * This is a method docblock. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index ba73e3d..e898a1a 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -230,6 +230,374 @@ public function test_function_docblocks() { ); } + /** + * Test that a nested union inside a generic remains one exported type. + */ + public function test_nested_union_inside_generic() { + + $this->assertFunctionHasDocs( + 'test_nested_union_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'A prompt.', + 'types' => array( + '\WordPress\AiClient\Messages\DTO\MessagePart', + 'list', + '\WordPress\AiClient\Messages\DTO\MessagePart[]', + ), + 'variable' => '$prompt', + ), + ), + ) + ); + } + + /** + * Test that a parenthesized union with an array suffix survives the export. + */ + public function test_grouped_union_type() { + + $this->assertFunctionHasDocs( + 'test_grouped_union_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Grouped.', + 'types' => array( + '(int|\WordPress\AiClient\Messages\DTO\MessagePart)[]', + ), + 'variable' => '$x', + ), + ), + ) + ); + } + + /** + * Test that a space inside a generic doesn't truncate the type. + */ + public function test_generic_type_with_space() { + + $this->assertFunctionHasDocs( + 'test_generic_type_with_space' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'A map.', + 'types' => array( 'array' ), + 'variable' => '$map', + ), + ), + ) + ); + } + + /** + * Test that a non-breaking space separates the type from the variable. + */ + public function test_nbsp_separated_type() { + + $this->assertFunctionHasDocs( + 'test_nbsp_separated_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'NBSP separated.', + 'types' => array( 'string' ), + 'variable' => '$nb', + ), + ), + ) + ); + } + + /** + * Test that an iterable generic isn't turned into a class name. + */ + public function test_iterable_generic_type() { + + $this->assertFunctionHasDocs( + 'test_iterable_generic_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Iterable.', + 'types' => array( + 'iterable<\WordPress\AiClient\Messages\DTO\MessagePart>', + ), + 'variable' => '$it', + ), + ), + ) + ); + } + + /** + * Test that an integer range return type keeps both of its bounds. + */ + public function test_int_range_return_type() { + + $this->assertFunctionHasDocs( + 'test_int_range_return_type' + , array( + 'tags' => array( + array( + 'name' => 'return', + 'content' => 'Count.', + 'types' => array( 'int<0,max>' ), + ), + ), + ) + ); + } + + /** + * Test that each member of an intersection type is resolved separately. + */ + public function test_intersection_type() { + + $this->assertFunctionHasDocs( + 'test_intersection_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Intersect.', + 'types' => array( + '\WordPress\AiClient\Messages\DTO\MessagePart&\Countable', + ), + 'variable' => '$both', + ), + ), + ) + ); + } + + /** + * Test that a bracket inside a quoted literal doesn't extend the type. + * + * The `<` is inside a string literal, so it doesn't open a bracket and the + * type ends at the first whitespace after the literal. Without that, the + * unclosed bracket swallows the variable and eats up to the `>` in the prose. + */ + public function test_quoted_literal_type() { + + $this->assertFunctionHasDocs( + 'test_quoted_literal_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'A description > with gt', + 'types' => array( "'a '$x', + ), + ), + ) + ); + } + + /** + * Test that an unclosed generic followed by prose falls back to a plain split. + * + * The whitespace after `int` isn't in a position where a type expression may + * contain whitespace, so the type expression is malformed and nothing better + * can be inferred than the plain split the legacy dependency made. + */ + public function test_unclosed_generic_type() { + + $this->assertFunctionHasDocs( + 'test_unclosed_generic_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'The arrow->prop blah', + 'types' => array( 'array '$x', + ), + ), + ) + ); + } + + /** + * Test that a generic which only balances at the last byte doesn't eat the description. + */ + public function test_unbalanced_generic_return_type() { + + $this->assertFunctionHasDocs( + 'test_unbalanced_generic_return_type' + , array( + 'tags' => array( + array( + 'name' => 'return', + 'content' => 'A generator of things>', + 'types' => array( 'GeneratorassertFunctionHasDocs( + 'test_callable_return_type' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Desc.', + 'types' => array( 'callable(int $a, string $b): bool' ), + 'variable' => '$cb', + ), + ), + ) + ); + } + + /** + * Test that a by-reference parameter's name is recovered. + */ + public function test_by_reference_param() { + + $this->assertFunctionHasDocs( + 'test_by_reference_param' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'By ref.', + 'types' => array( 'array' ), + 'variable' => '$arr', + ), + ), + ) + ); + } + + /** + * Test that a leading group full of prose doesn't swallow the description. + * + * A `(` which follows an identifier opens a callable's parameter list, where + * whitespace separates one parameter from the next. A `(` which follows + * anything else opens a group which holds a single nested expression, and + * prose inside one of those means it isn't a type expression at all. Nothing + * better can be inferred then than the plain split the legacy dependency made. + */ + public function test_leading_group_prose_return_type() { + + $this->assertFunctionHasDocs( + 'test_leading_group_prose_return_type' + , array( + 'tags' => array( + array( + 'name' => 'return', + 'content' => 'depends on context)', + 'types' => array( '(mixed' ), + ), + ), + ) + ); + } + + /** + * Test that a colon after a leading group doesn't continue the type. + * + * A return type is written after a callable's parameter list, so the + * whitespace which follows the colon is inside the type expression. A group + * which holds a nested expression has no return type to continue into. + */ + public function test_leading_group_colon_return_type() { + + $this->assertFunctionHasDocs( + 'test_leading_group_colon_return_type' + , array( + 'tags' => array( + array( + 'name' => 'return', + 'content' => 'true on success', + 'types' => array( '(bool):' ), + ), + ), + ) + ); + } + + /** + * Test that a callable with an empty parameter list keeps its return type. + */ + public function test_empty_callable_param() { + + $this->assertFunctionHasDocs( + 'test_empty_callable_param' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'Empty.', + 'types' => array( 'callable(): bool' ), + 'variable' => '$cb', + ), + ), + ) + ); + } + + /** + * Test that an untyped by-reference parameter's name is recovered. + * + * The legacy dependency only recognizes a name written as `$name` or + * `...$name`, so `&$arr` is read as the type expression and the parameter + * loses its name. There is no type expression there to publish. + */ + public function test_untyped_by_reference_param() { + + $this->assertFunctionHasDocs( + 'test_untyped_by_reference_param' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'By ref desc.', + 'types' => array(), + 'variable' => '$arr', + ), + ), + ) + ); + } + + /** + * Test that an untyped variadic by-reference parameter's name is recovered. + */ + public function test_untyped_variadic_by_reference_param() { + + $this->assertFunctionHasDocs( + 'test_untyped_variadic_by_reference_param' + , array( + 'tags' => array( + array( + 'name' => 'param', + 'content' => 'The rest.', + 'types' => array(), + 'variable' => '$rest', + ), + ), + ) + ); + } + /** * Test that class docs are exported. */ @@ -1256,4 +1624,51 @@ private function file_greeting_setup_blueprints() { ), ); } + + /** + * Test that a callable property type keeps its return type. + */ + public function test_callable_property_type() { + + $this->assertPropertyHasDocs( + 'Test_Class' + , '$a_callback' + , array( + 'tags' => array( + array( + 'name' => 'var', + 'content' => '', + 'types' => array( 'callable(string): string' ), + 'variable' => '', + ), + ), + ) + ); + } + + /** + * Test that a tag whose content starts with a variable is exported as before. + * + * The legacy dependency treats the whole first token as the variable name and + * leaves nothing for a type. Nothing can be recovered from that, so the + * export has to match the legacy export byte for byte rather than rewriting + * half of it. + */ + public function test_variable_first_property_type() { + + $this->assertPropertyHasDocs( + 'Test_Class' + , '$a_shape' + , array( + 'tags' => array( + array( + 'name' => 'var', + 'content' => 'string} Some desc', + 'types' => array(), + 'variable' => '$map{int,', + ), + ), + ) + ); + } } diff --git a/tests/phpunit/tests/export/tag-content.php b/tests/phpunit/tests/export/tag-content.php new file mode 100644 index 0000000..4adf62e --- /dev/null +++ b/tests/phpunit/tests/export/tag-content.php @@ -0,0 +1,188 @@ +assertSame( $expected, split_docblock_tag_content( $content ) ); + } + + /** + * Data provider for tag contents. + * + * @return array[] The tag content, and its expected split. + */ + public function data_tag_contents() { + + return array( + 'quoted literal containing a bracket' => array( + "'a with gt" + , array( "'a with gt' ) + ), + 'unclosed generic followed by prose' => array( + 'arrayprop blah' + , array( 'arrayprop blah' ) + ), + 'generic which balances at the last byte' => array( + 'Generator' + , array( 'Generator' ) + ), + 'callable with a return type' => array( + 'callable(int $a, string $b): bool $cb Desc.' + , array( 'callable(int $a, string $b): bool', '$cb Desc.' ) + ), + 'callable with a return type and nothing after it' => array( + 'callable(string): string' + , array( 'callable(string): string', '' ) + ), + 'callable with an empty parameter list' => array( + 'callable(): bool $cb Empty.' + , array( 'callable(): bool', '$cb Empty.' ) + ), + 'leading group full of prose' => array( + '(mixed depends on context)' + , array( '(mixed', 'depends on context)' ) + ), + 'leading group followed by a colon' => array( + '(bool): true on success' + , array( '(bool):', 'true on success' ) + ), + 'grouped union followed by a variable' => array( + '(int|string)[] $x Grouped.' + , array( '(int|string)[]', '$x Grouped.' ) + ), + 'generic followed by a by-reference variable' => array( + 'array &$arr By ref.' + , array( 'array', '&$arr By ref.' ) + ), + 'array shape which begins with a variable' => array( + '$map{int, string} Some desc' + , array( '$map{int, string}', 'Some desc' ) + ), + 'array shape' => array( + 'array{a: int, b: string} $s Shape.' + , array( 'array{a: int, b: string}', '$s Shape.' ) + ), + 'generic with a space after the delimiter' => array( + 'array $map A map.' + , array( 'array', '$map A map.' ) + ), + 'plain type' => array( + 'string $var A string value.' + , array( 'string', '$var A string value.' ) + ), + ); + } + + /** + * Test that content which isn't valid UTF-8 is split bytewise. + */ + public function test_split_docblock_tag_content_of_invalid_utf8() { + + $this->assertSame( + array( "array", "\xFF\$var Desc" ) + , split_docblock_tag_content( "array \xFF\$var Desc" ) + ); + } + + /** + * Test that a tag whose content isn't valid UTF-8 is exported as parsed. + * + * Content which isn't valid UTF-8 is split bytewise, so the type expression + * may contain whitespace which only a Unicode-mode match would find. Deciding + * whether to re-derive the variable and the description with a Unicode-mode + * match leaves the two halves of the export disagreeing: the description is + * rewritten from a split which never happened, while the variable isn't. + * + * The legacy dependency can't parse a DocBlock which isn't valid UTF-8 at all + * -- it returns no tags -- so the tag is built directly here. + */ + public function test_export_docblock_of_invalid_utf8_content() { + + $context = new Context( '\Ns' ); + $docblock = new DocBlock( "/**\n * Summary.\n */", $context ); + $tag = new Invalid_UTF8_Param_Tag( 'param', 'array $var Desc', $docblock ); + + $docblock->appendTag( $tag ); + + $exported = export_docblock( new Docblock_Bearing_Element( $docblock ) ); + + $this->assertSame( '$var', $exported['tags'][0]['variable'] ); + $this->assertSame( 'Desc', $exported['tags'][0]['content'] ); + } +} + +/** + * A param tag whose content isn't valid UTF-8. + * + * The content is overridden rather than passed to the constructor because the + * legacy dependency emits a PHP warning while parsing content which isn't valid + * UTF-8. + */ +class Invalid_UTF8_Param_Tag extends ParamTag { + + /** + * @return string + */ + public function getContent() { + + return "array \xFF\$var Desc"; + } +} + +/** + * A stand-in for a reflector, which is all that the exporter needs of one. + */ +class Docblock_Bearing_Element { + + /** + * @var DocBlock + */ + protected $docblock; + + /** + * @param DocBlock $docblock The DocBlock to export. + */ + public function __construct( $docblock ) { + + $this->docblock = $docblock; + } + + /** + * @return DocBlock + */ + public function getDocBlock() { + + return $this->docblock; + } +} diff --git a/tests/phpunit/tests/export/type-expressions.php b/tests/phpunit/tests/export/type-expressions.php new file mode 100644 index 0000000..c536a05 --- /dev/null +++ b/tests/phpunit/tests/export/type-expressions.php @@ -0,0 +1,245 @@ +assertSame( + $expected + , expand_docblock_type_expression( $type, new Context( '\Ns' ) ) + ); + } + + /** + * Data provider for type expressions expanded within the `\Ns` namespace. + * + * @return array[] The type expression, and its expected expansion. + */ + public function data_namespaced_type_expressions() { + + return array( + 'parenthesized union with an array suffix' => array( + '(int|string)[]' + , '(int|string)[]' + ), + 'array shape' => array( + 'array{a: int|string}' + , 'array{a: int|string}' + ), + 'callable signature' => array( + 'callable(int|string): void' + , 'callable(int|string): void' + ), + 'unbalanced brackets' => array( + 'array array( + 'int<0,max>' + , 'int<0,max>' + ), + 'integer range with a lower bound keyword' => array( + 'int' + , 'int' + ), + 'max keyword' => array( + 'max' + , 'max' + ), + 'min keyword' => array( + 'min' + , 'min' + ), + 'class named like the max keyword' => array( + 'Max' + , '\Ns\Max' + ), + 'class named like the min keyword' => array( + 'Min' + , '\Ns\Min' + ), + 'class constant' => array( + 'Base::TYPE_FEED' + , '\Ns\Base::TYPE_FEED' + ), + 'class constant wildcard' => array( + 'Foo::*' + , '\Ns\Foo::*' + ), + 'class constant wildcard on a keyword' => array( + 'self::LOCATOR_*' + , 'self::LOCATOR_*' + ), + 'nullable class name' => array( + '?Foo' + , '?\Ns\Foo' + ), + 'nullable class name with an array suffix' => array( + '?Foo[]' + , '?\Ns\Foo[]' + ), + 'nullable keyword' => array( + '?string' + , '?string' + ), + 'nullable never keyword' => array( + '?never' + , '?never' + ), + 'generic with a leading integer literal' => array( + 'array<0,string>' + , 'array<0,string>' + ), + 'generic with a quoted string literal' => array( + "array" + , "array" + ), + 'iterable generic' => array( + 'iterable' + , 'iterable<\Ns\Foo>' + ), + 'class-string generic' => array( + 'class-string' + , 'class-string<\Ns\Foo>' + ), + 'non-empty-list generic' => array( + 'non-empty-list' + , 'non-empty-list' + ), + 'never keyword' => array( + 'never' + , 'never' + ), + 'generic with a space after the delimiter' => array( + 'array' + , 'array' + ), + 'repeated array suffixes' => array( + 'Foo[][]' + , '\Ns\Foo[][]' + ), + 'generic with a non-breaking space after the delimiter' => array( + "array" + , 'array' + ), + 'type padded with non-breaking spaces' => array( + "\xC2\xA0Foo\xC2\xA0" + , '\Ns\Foo' + ), + 'group containing a quoted closing parenthesis' => array( + '(\'a)\'|Foo)' + , '(\'a)\'|\Ns\Foo)' + ), + 'expression which merely starts and ends with a group' => array( + '(int)foo(string)' + , '(int)foo(string)' + ), + ); + } + + /** + * Test type expressions expanded with an aliased namespace. + * + * @dataProvider data_aliased_type_expressions + * + * @param string $type The type expression to expand. + * @param string $expected The expected expansion. + */ + public function test_aliased_type_expressions( $type, $expected ) { + + $context = new Context( '\Acme', array( 'Bar' => 'Vendor\Bar' ) ); + + $this->assertSame( $expected, expand_docblock_type_expression( $type, $context ) ); + } + + /** + * Data provider for type expressions expanded with an aliased namespace. + * + * @return array[] The type expression, and its expected expansion. + */ + public function data_aliased_type_expressions() { + + return array( + 'intersection' => array( + 'Bar&Foo' + , '\Vendor\Bar&\Acme\Foo' + ), + 'intersection inside a generic' => array( + 'list' + , 'list<\Vendor\Bar&\Acme\Foo>' + ), + 'nullable alias' => array( + '?Bar' + , '?\Vendor\Bar' + ), + ); + } + + /** + * Test that a great many array suffixes are expanded without quadratic blowup. + */ + public function test_repeated_array_suffixes_do_not_blow_up() { + + $type = 'int' . str_repeat( '[]', 20000 ); + + $start = microtime( true ); + $expanded = expand_docblock_type_expression( $type, new Context( '\Ns' ) ); + $elapsed = microtime( true ) - $start; + + $this->assertTrue( $type === $expanded, 'The expanded type expression should be unchanged.' ); + $this->assertLessThan( + 5 + , $elapsed + , 'Expanding repeated array suffixes should not take quadratic time.' + ); + } + + /** + * Test that deeply nested generics are expanded without quadratic blowup. + * + * Each level of nesting rescans and copies the whole remaining expression, so + * the cost grows with the square of the length of the expression rather than + * with its length. + * + * The depth is chosen so that the expansion currently takes about two seconds + * while staying under a hundred and thirty megabytes, which is the smallest + * memory limit this runs under. + */ + public function test_deeply_nested_generics_do_not_blow_up() { + + $depth = 2200; + $type = str_repeat( 'array<', $depth ) . 'int' . str_repeat( '>', $depth ); + + $start = microtime( true ); + $expanded = expand_docblock_type_expression( $type, new Context( '\Ns' ) ); + $elapsed = microtime( true ) - $start; + + $this->assertTrue( $type === $expanded, 'The expanded type expression should be unchanged.' ); + $this->assertLessThan( + 1 + , $elapsed + , 'Expanding deeply nested generics should not take quadratic time.' + ); + } +} diff --git a/tests/phpunit/tests/plugin/args-safe.php b/tests/phpunit/tests/plugin/args-safe.php new file mode 100644 index 0000000..aa7d6ba --- /dev/null +++ b/tests/phpunit/tests/plugin/args-safe.php @@ -0,0 +1,194 @@ + '$prompt', + 'default' => null, + 'type' => '\WordPress\AiClient\Messages\DTO\MessagePart', + ), + array( + 'name' => '$list', + 'default' => null, + 'type' => 'list', + ), + array( + 'name' => '$evil', + 'default' => null, + 'type' => '', + ), + ) + ); + } + + /** + * Filter arguments shaped the way `get_arguments()` builds them. + * + * @return array[] The filtered arguments. + */ + protected function filter_real_arguments() { + + return apply_filters( + 'wp_parser_get_arguments' + , array( + array( + 'name' => '$x', + 'types' => array( 'string' ), + 'default_value' => '', + 'desc' => 'A Bold description.', + ), + array( + 'name' => '$cb', + 'types' => array( 'callable(int $a, string $b): bool' ), + ), + array( + 'name' => '$post', + 'types' => array( 'callable(\WP_Post $p): void' ), + ), + array( + 'name' => '$map', + 'types' => array( 'array' ), + ), + array( + 'name' => '$bold', + 'types' => array( 'string' ), + 'desc' => 'hello', + ), + ) + ); + } + + /** + * Test that markup in a default value is neutralized. + * + * A default value isn't a type expression and isn't escaped where it's + * printed, so an unclosed tag in one lands in the page as written. + */ + public function test_markup_in_a_default_value_is_neutralized() { + + $arguments = $this->filter_real_arguments(); + + $this->assertSame( '', $arguments[0]['default_value'] ); + } + + /** + * Test that markup in a description is neutralized. + */ + public function test_markup_in_a_description_is_neutralized() { + + $arguments = $this->filter_real_arguments(); + + $this->assertSame( + 'A Bold description.' + , $arguments[0]['desc'] + ); + } + + /** + * Test that a description which reads as a type expression is neutralized. + * + * A description is prose wherever it happens to be written with the + * characters a type expression is written with, and an unclosed tag in one + * swallows the rest of the page. + */ + public function test_type_shaped_markup_in_a_description_is_neutralized() { + + $arguments = $this->filter_real_arguments(); + + $this->assertSame( 'hello', $arguments[4]['desc'] ); + } + + /** + * Test that a callable's parameter list survives. + */ + public function test_callable_signature_survives() { + + $arguments = $this->filter_real_arguments(); + + $this->assertSame( + array( 'callable(int $a, string $b): bool' ) + , $arguments[1]['types'] + ); + } + + /** + * Test that a callable's parameter list keeps its namespace separators. + */ + public function test_callable_signature_keeps_namespace_separators() { + + $arguments = $this->filter_real_arguments(); + + $this->assertSame( + array( 'callable(\WP_Post $p): void' ) + , $arguments[2]['types'] + ); + } + + /** + * Test that a generic keeps a run of whitespace after its delimiter. + */ + public function test_generic_with_repeated_whitespace_survives() { + + $arguments = $this->filter_real_arguments(); + + $this->assertSame( array( 'array' ), $arguments[3]['types'] ); + } + + /** + * Test that a fully qualified class name keeps its namespace separators. + */ + public function test_namespace_separators_survive() { + + $arguments = $this->filter_arguments(); + + $this->assertSame( + '\WordPress\AiClient\Messages\DTO\MessagePart' + , $arguments[0]['type'] + ); + } + + /** + * Test that a generic type keeps its brackets. + */ + public function test_generic_type_survives() { + + $arguments = $this->filter_arguments(); + + $this->assertSame( 'list', $arguments[1]['type'] ); + } + + /** + * Test that markup in a type isn't passed through as markup. + */ + public function test_markup_in_a_type_is_not_passed_through() { + + $arguments = $this->filter_arguments(); + + $this->assertStringNotContainsString( '