diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 9e5ea6a9..bac77065 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -27,7 +27,8 @@ jobs: - name: Setup Environment run: | rm composer.lock - npm run setup + npm run start + npm run composer -- install --no-security-blocking - name: Test run: npm run test diff --git a/lib/class-command.php b/lib/class-command.php index 4fdad914..ee93eb41 100644 --- a/lib/class-command.php +++ b/lib/class-command.php @@ -34,7 +34,10 @@ public function export( $args ) { } /** - * Read a JSON file containing the PHPDoc markup, convert it into WordPress posts, and insert into DB. + * Imports an exported parser document into WordPress. + * + * The command validates the parsed-file envelope before invoking the importer + * and preserves setup Blueprint object and list shapes while decoding JSON. * * @synopsis [--quick] [--import-internal] * @@ -57,11 +60,47 @@ public function import( $args, $assoc_args ) { exit; } - $phpdoc = json_decode( $phpdoc, true ); - if ( is_null( $phpdoc ) ) { + $phpdoc = json_decode( $phpdoc ); + if ( JSON_ERROR_NONE !== json_last_error() ) { WP_CLI::error( sprintf( "JSON in %1\$s can't be decoded :(", $file ) ); exit; } + if ( ! is_array( $phpdoc ) ) { + WP_CLI::error( sprintf( 'JSON in %1$s must contain a top-level list of parsed files.', $file ) ); + exit; + } + preserve_json_object_shapes( $phpdoc ); + + // The importer dereferences these fields before it can report malformed + // input. Validate the file envelope here so bad JSON data produces one + // actionable CLI error instead of array-offset warnings or a type error. + foreach ( $phpdoc as $index => $parsed_file ) { + if ( + ! is_array( $parsed_file ) || + ! isset( $parsed_file['path'], $parsed_file['file'] ) || + ! is_string( $parsed_file['path'] ) || + '' === $parsed_file['path'] || + ! is_array( $parsed_file['file'] ) || + ! isset( + $parsed_file['file']['description'], + $parsed_file['file']['long_description'], + $parsed_file['file']['tags'] + ) || + ! is_string( $parsed_file['file']['description'] ) || + ! is_string( $parsed_file['file']['long_description'] ) || + ! is_array( $parsed_file['file']['tags'] ) || + array_values( $parsed_file['file']['tags'] ) !== $parsed_file['file']['tags'] + ) { + WP_CLI::error( + sprintf( + 'JSON in %1$s entry %2$d must contain a parsed file object with a path and file metadata.', + $file, + $index + 1 + ) + ); + exit; + } + } // Import data $this->_do_import( $phpdoc, isset( $assoc_args['quick'] ), isset( $assoc_args['import-internal'] ) ); diff --git a/lib/class-file-reflector.php b/lib/class-file-reflector.php index 62f401d7..f9d18406 100644 --- a/lib/class-file-reflector.php +++ b/lib/class-file-reflector.php @@ -44,6 +44,77 @@ class File_Reflector extends FileReflector { */ protected $last_doc = null; + /** + * Whether complete fence bodies were blanked before parsing the file DocBlock. + * + * @var bool + */ + protected $docblock_was_sanitized = false; + + /** + * Indicates whether complete file-DocBlock fence bodies were blanked. + * + * @return bool Whether phpDocumentor parsed a sanitized comment. + */ + public function wasDocBlockSanitized() { + return $this->docblock_was_sanitized; + } + + /** + * Lets phpDocumentor identify a file DocBlock without parsing fenced PHP as tags. + * + * `FileReflector` consumes the file comment before visiting its nodes, so + * `export_docblock()` cannot sanitize it later. Complete fence bodies are + * temporarily blanked for the parent traversal and restored afterward; the + * untouched file contents remain available for snippet extraction. + * + * @param PHPParser_Node[] $nodes + * + * @return PHPParser_Node[] + */ + public function beforeTraverse( array $nodes ) { + $source_docblock = null; + $docblock = null; + + foreach ( $nodes as $node ) { + if ( $node instanceof \PhpParser\Node\Stmt\InlineHTML ) { + continue; + } + + foreach ( (array) $node->getAttribute( 'comments' ) as $comment ) { + if ( $comment instanceof \PhpParser\Comment\Doc ) { + $docblock = $comment; + $source_docblock = (string) $comment; + break; + } + } + break; + } + + if ( null !== $source_docblock && false !== strpos( $source_docblock, '```' ) ) { + $sanitized_docblock = sanitize_docblock_fenced_contents( $source_docblock ); + if ( $sanitized_docblock !== $source_docblock ) { + $docblock->setText( $sanitized_docblock ); + $this->docblock_was_sanitized = true; + } + } + + try { + $nodes = parent::beforeTraverse( $nodes ); + } catch ( \Exception $exception ) { + if ( null !== $source_docblock ) { + $docblock->setText( $source_docblock ); + } + throw $exception; + } + + if ( null !== $source_docblock ) { + $docblock->setText( $source_docblock ); + } + + return $nodes; + } + /** * Add hooks to the queue and update the node stack when we enter a node. * @@ -144,6 +215,25 @@ public function leaveNode( \PHPParser_Node $node ) { parent::leaveNode( $node ); switch ( $node->getType() ) { + case 'Stmt_Property': + /* + * PropertyReflector::getDocBlock() reads the declaration node, while + * getNode() returns one PropertyProperty child. Copy the declaration's + * comment to each child after it has been visited so export_docblock() can + * recover the raw fenced text through getNode(). Copying it earlier would + * make enterNode() queue the non-documentable child's comment for the next + * hook. + */ + $docblock = $node->getDocComment(); + if ( $docblock ) { + foreach ( $node->props as $property ) { + $comments = (array) $property->getAttribute( 'comments' ); + $comments[] = $docblock; + $property->setAttribute( 'comments', $comments ); + } + } + break; + case 'Stmt_Class': $class = end( $this->classes ); if ( ! empty( $this->method_uses_queue ) ) { diff --git a/lib/class-importer.php b/lib/class-importer.php index bc723723..c1c264e1 100644 --- a/lib/class-importer.php +++ b/lib/class-importer.php @@ -760,6 +760,14 @@ public function import_item( array $data, $parent_post_id = 0, $import_ignored = $anything_updated[] = update_post_meta( $post_id, '_wp-parser_line_num', (string) $data['line'] ); $anything_updated[] = update_post_meta( $post_id, '_wp-parser_end_line_num', (string) $data['end_line'] ); $anything_updated[] = update_post_meta( $post_id, '_wp-parser_tags', $data['doc']['tags'] ); + + // Metadata APIs unslash their input. map_deep() reaches retained JSON + // objects as well as arrays, preserving backslashes in PHP and Blueprint + // source where wp_slash() alone would leave object properties untouched. + $code_snippets = isset( $data['doc']['code_snippets'] ) ? $data['doc']['code_snippets'] : array(); + $setup_blueprints = isset( $data['doc']['setup_blueprints'] ) ? $data['doc']['setup_blueprints'] : array(); + $anything_updated[] = update_post_meta( $post_id, '_wp-parser_code_snippets', map_deep( $code_snippets, 'wp_slash' ) ); + $anything_updated[] = update_post_meta( $post_id, '_wp-parser_setup_blueprints', map_deep( $setup_blueprints, 'wp_slash' ) ); $anything_updated[] = update_post_meta( $post_id, '_wp-parser_last_parsed_wp_version', $this->version ); // If the post didn't need to be updated, but meta or tax changed, update it to bump last modified. diff --git a/lib/runner.php b/lib/runner.php index ba3efdd4..4a99c38f 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -3,6 +3,7 @@ namespace WP_Parser; use phpDocumentor\Reflection\BaseReflector; +use phpDocumentor\Reflection\ClassReflector; use phpDocumentor\Reflection\ClassReflector\MethodReflector; use phpDocumentor\Reflection\ClassReflector\PropertyReflector; use phpDocumentor\Reflection\FunctionReflector; @@ -39,10 +40,15 @@ function get_wp_files( $directory ) { } /** - * @param array $files - * @param string $root + * Parses PHP files into records consumed by the importer. * - * @return array + * Setup Blueprints flow from file and class DocBlocks to descendants. A + * descendant copies only definitions referenced by one of its snippets. + * + * @param string[] $files PHP source files to parse. + * @param string $root Root path removed from exported file paths. + * + * @return array Parsed file records in input order. */ function parse_files( $files, $root ) { $output = array(); @@ -55,9 +61,12 @@ function parse_files( $files, $root ) { $file->process(); + $file_doc = export_docblock( $file, array(), $path ); + $file_setup_blueprints = isset( $file_doc['setup_blueprints'] ) ? $file_doc['setup_blueprints'] : array(); + // TODO proper exporter $out = array( - 'file' => export_docblock( $file ), + 'file' => $file_doc, 'path' => str_replace( DIRECTORY_SEPARATOR, '/', $file->getFilename() ), 'root' => $root, ); @@ -83,7 +92,7 @@ function parse_files( $files, $root ) { } if ( ! empty( $file->uses['hooks'] ) ) { - $out['hooks'] = export_hooks( $file->uses['hooks'] ); + $out['hooks'] = export_hooks( $file->uses['hooks'], $file_setup_blueprints, $path ); } foreach ( $file->getFunctions() as $function ) { @@ -94,7 +103,7 @@ function parse_files( $files, $root ) { 'line' => $function->getLineNumber(), 'end_line' => $function->getNode()->getAttribute( 'endLine' ), 'arguments' => export_arguments( $function->getArguments() ), - 'doc' => export_docblock( $function ), + 'doc' => export_docblock( $function, $file_setup_blueprints, $path ), 'hooks' => array(), ); @@ -102,7 +111,7 @@ function parse_files( $files, $root ) { $func['uses'] = export_uses( $function->uses ); if ( ! empty( $function->uses['hooks'] ) ) { - $func['hooks'] = export_hooks( $function->uses['hooks'] ); + $func['hooks'] = export_hooks( $function->uses['hooks'], $file_setup_blueprints, $path ); } } @@ -110,6 +119,9 @@ function parse_files( $files, $root ) { } foreach ( $file->getClasses() as $class ) { + $class_doc = export_docblock( $class, $file_setup_blueprints, $path ); + $class_setup_blueprints = array_merge( $file_setup_blueprints, isset( $class_doc['setup_blueprints'] ) ? $class_doc['setup_blueprints'] : array() ); + $class_data = array( 'name' => $class->getShortName(), 'namespace' => $class->getNamespace(), @@ -119,9 +131,9 @@ function parse_files( $files, $root ) { 'abstract' => $class->isAbstract(), 'extends' => $class->getParentClass(), 'implements' => $class->getInterfaces(), - 'properties' => export_properties( $class->getProperties() ), - 'methods' => export_methods( $class->getMethods() ), - 'doc' => export_docblock( $class ), + 'properties' => export_properties( $class->getProperties(), $class_setup_blueprints, $path ), + 'methods' => export_methods( $class->getMethods(), $class_setup_blueprints, $path ), + 'doc' => $class_doc, ); $out['classes'][] = $class_data; @@ -189,12 +201,55 @@ function ( $matches ) use ( $replacement_string ) { } /** - * @param BaseReflector|ReflectionAbstract $element + * Exports one reflected DocBlock and its runnable snippet metadata. * - * @return array + * Fenced descriptions are recovered from source because phpDocumentor may + * interpret PHP lines beginning with `@` as tags. Named setup references may + * resolve against definitions inherited from the enclosing file or class. + * + * @param BaseReflector|ReflectionAbstract $element Reflected DocBlock owner. + * @param array $inherited_setup_blueprints Setup Blueprints visible from enclosing scopes. + * @param string $source_file Source path used in metadata errors. + * + * @throws \InvalidArgumentException When snippet metadata is invalid or ambiguous. + * + * @return array Exported descriptions, tags, snippets, and referenced setup Blueprints. */ -function export_docblock( $element ) { +function export_docblock( $element, array $inherited_setup_blueprints = array(), $source_file = '' ) { + $node_docblock = null; + $node_source_docblock = null; + $docblock_was_sanitized = $element instanceof File_Reflector && $element->wasDocBlockSanitized(); + if ( ! ( $element instanceof File_Reflector ) && method_exists( $element, 'getNode' ) ) { + $node = $element->getNode(); + if ( $node && method_exists( $node, 'getDocComment' ) ) { + $node_docblock = $node->getDocComment(); + if ( $node_docblock ) { + $node_source_docblock = (string) $node_docblock; + } + } + } + $docblock = $element->getDocBlock(); + if ( ! $docblock && null !== $node_source_docblock && false !== strpos( $node_source_docblock, '```' ) ) { + /* + * phpDocumentor does not recognize fences. A fenced line beginning with `@` + * starts its tag block, and a later PHP expression such as `@! file_exists()` + * can make the whole DocBlock fail to parse. + * + * Blank only the bodies of complete fences and retry, leaving the fence + * markers and line structure intact. Restore the original AST comment + * immediately afterward. The parsed object supplies tags and namespace + * context; the untouched $node_source_docblock supplies descriptions and + * snippet code below. + */ + $sanitized_docblock = sanitize_docblock_fenced_contents( $node_source_docblock ); + if ( $sanitized_docblock !== $node_source_docblock ) { + $node_docblock->setText( $sanitized_docblock ); + $docblock = $element->getDocBlock(); + $node_docblock->setText( $node_source_docblock ); + $docblock_was_sanitized = (bool) $docblock; + } + } if ( ! $docblock ) { return array( 'description' => '', @@ -202,14 +257,224 @@ function export_docblock( $element ) { 'tags' => array(), ); } + $fenced_docblock_tag_names = array(); + + try { + $short_description = $docblock->getShortDescription(); + $raw_long_description = $docblock->getLongDescription()->getContents(); + $source_docblock = null; + + /* + * phpDocumentor can split one fenced block across its short and long + * descriptions, alter its line structure, and parse `@` code as tags. For + * example, this source: + * + * ```php interactive + * getLocation(); + if ( $location && $location->getLineNumber() ) { + $source_lines = explode( "\n", preg_replace( "/\r\n?/", "\n", $element->getContents() ) ); + $source_line = $location->getLineNumber() - 1; + $source_line_count = count( $source_lines ); + if ( isset( $source_lines[ $source_line ] ) ) { + $opening = strpos( $source_lines[ $source_line ], '/**' ); + if ( false !== $opening ) { + $source_lines[ $source_line ] = substr( $source_lines[ $source_line ], $opening ); + $source_docblock_lines = array(); + for ( ; $source_line < $source_line_count; $source_line++ ) { + $source_docblock_lines[] = $source_lines[ $source_line ]; + if ( false !== strpos( $source_lines[ $source_line ], '*/' ) ) { + break; + } + } + $source_docblock = implode( "\n", $source_docblock_lines ); + } + } + } + } elseif ( null !== $node_source_docblock ) { + $source_docblock = $node_source_docblock; + } + } + + if ( null !== $source_docblock ) { + /* + * Recover exact description lines from source and stop at the first tag + * outside a fence. For example: + * + * ```php + * @since( 'inside-fence' ); + * ``` + * @since 1.0.0 + * + * The first `@since` remains snippet code; the second ends the description. + * Record the first name so only phpDocumentor's false in-fence tag is + * removed from the parsed tags, leaving the real `@since 1.0.0` tag. + */ + $source_docblock = preg_replace( "/\r\n?/", "\n", $source_docblock ); + $source_docblock = preg_replace( '/\A[ \t]*\/\*\*[ \t]?/', '', $source_docblock ); + $source_docblock = preg_replace( '/[ \t]*\*\/[ \t]*\z/', '', $source_docblock ); + $source_lines = explode( "\n", $source_docblock ); + foreach ( $source_lines as $key => $source_line ) { + $source_lines[ $key ] = preg_replace( '/^[ \t]*\*[ \t]?/', '', $source_line ); + } + + // Reuse tokenizer boundaries so source recovery and snippet export agree + // on which exact backtick runs delimit complete fences. + $source_fences = tokenize_docblock_code_fences( implode( "\n", $source_lines ) ); + $source_fence_index = 0; + $description_lines = array(); + $parsed_tag_block_started = false; + foreach ( $source_lines as $source_line_number => $source_line ) { + while ( + isset( $source_fences[ $source_fence_index ] ) && + $source_line_number >= $source_fences[ $source_fence_index ]['end'] + ) { + $source_fence_index++; + } + $is_in_fence = isset( $source_fences[ $source_fence_index ] ) && + $source_line_number > $source_fences[ $source_fence_index ]['start']; + + /* + * Before tag parsing starts, `^[ \t]*` permits indentation and `\pL` + * requires a Unicode letter: ` @since` matches, while `@_before` does not. + * Once started, `^@` requires column zero and the broader name class permits + * an initial underscore or digit: `@_same` and `@2inside` match, while + * ` @author` remains part of the preceding tag. + */ + $tag_pattern = $parsed_tag_block_started + ? '/^@([\w\-_\\\\]+)/u' + : '/^[ \t]*@([\pL][\w\-_\\\\]*)/u'; + if ( preg_match( $tag_pattern, $source_line, $tag_match ) ) { + if ( ! $is_in_fence ) { + break; + } + $parsed_tag_block_started = true; + $fenced_docblock_tag_names[] = $tag_match[1]; + } + + $description_lines[] = $source_line; + } + // Remove blank wrapper lines without stripping indentation from a fence + // that begins or ends the description. That indentation controls both + // content dedenting and the fence's Markdown nesting level. + $description_edge_pattern = '/\A(?:[ \t]*\n)+|(?:\n[ \t]*)+\z/'; + $source_description = preg_replace( $description_edge_pattern, '', implode( "\n", $description_lines ) ); + if ( $docblock_was_sanitized ) { + // Sanitized parsing never created the false in-fence tags, so every + // parsed tag belongs to the actual DocBlock tag section. + $fenced_docblock_tag_names = array(); + } + + if ( preg_match( '/(?:\A|\n)[ \t]*`{3,}[^`\n]*(?=\n|\z)/', $short_description ) ) { + $raw_long_description = $source_description; + $short_description = ''; + } elseif ( '' !== $short_description && 0 === strpos( $source_description, $short_description ) ) { + $raw_long_description = preg_replace( $description_edge_pattern, '', substr( $source_description, strlen( $short_description ) ) ); + } elseif ( '' !== $raw_long_description ) { + $long_description_start = strpos( $source_description, $raw_long_description ); + if ( false !== $long_description_start ) { + $raw_long_description = preg_replace( $description_edge_pattern, '', substr( $source_description, $long_description_start ) ); + } + } + } + + // phpDocumentor assigns the first DocBlock paragraph to the short + // description and can split it at a blank line inside a fence. Detect an + // opening line without requiring its closer, then rejoin both descriptions + // before parsing so the fence retains its line structure and metadata pairing. + if ( '' !== $short_description && preg_match( '/(?:\A|\n)[ \t]*`{3,}[^`\n]*(?=\n|\z)/', $short_description ) ) { + $raw_long_description = $short_description . ( '' === $raw_long_description ? '' : "\n\n" . $raw_long_description ); + $short_description = ''; + } + + $fences = get_docblock_code_fences( $raw_long_description ); + + // Reusing an enclosing name would make the same reference resolve to + // different setup depending on which DocBlock is being exported. + foreach ( $fences as $fence ) { + if ( + null !== $fence['setup_name'] && + array_key_exists( $fence['setup_name'], $inherited_setup_blueprints ) + ) { + throw new \InvalidArgumentException( + 'Setup Blueprint "' . $fence['setup_name'] . '" on line ' . ( $fence['start'] + 1 ) . + ' of the long description is already defined in an enclosing DocBlock.' + ); + } + } + + $setup_blueprints = array(); + $code_snippets = export_docblock_code_snippets( $raw_long_description, $setup_blueprints, $fences ); + + // Copy only referenced inherited setups into this DocBlock's output. Each + // imported post then contains everything its snippets need without copying + // every file- or class-level setup into every descendant. + $referenced_inherited_setup_blueprints = array(); + $snippet_lines = array(); + foreach ( $fences as $fence ) { + if ( $fence['is_interactive_php'] ) { + $snippet_lines[ $fence['snippet_index'] ] = $fence['start'] + 1; + } + } + foreach ( $code_snippets as $index => $snippet ) { + if ( ! isset( $snippet['blueprint'] ) || ! is_string( $snippet['blueprint'] ) ) { + continue; + } + + if ( array_key_exists( $snippet['blueprint'], $inherited_setup_blueprints ) ) { + $referenced_inherited_setup_blueprints[ $snippet['blueprint'] ] = $inherited_setup_blueprints[ $snippet['blueprint'] ]; + continue; + } + + if ( ! array_key_exists( $snippet['blueprint'], $setup_blueprints ) ) { + throw new \InvalidArgumentException( + 'Setup Blueprint "' . $snippet['blueprint'] . '" referenced on line ' . + $snippet_lines[ $index ] . ' of the long description is not defined.' + ); + } + } + $setup_blueprints = array_merge( $referenced_inherited_setup_blueprints, $setup_blueprints ); + } catch ( \InvalidArgumentException $exception ) { + throw new \InvalidArgumentException( + describe_docblock_source( $element, $docblock, $source_file ) . ': ' . $exception->getMessage(), + 0, + $exception + ); + } $output = array( - 'description' => preg_replace( '/[\n\r]+/', ' ', $docblock->getShortDescription() ), - 'long_description' => fix_newlines( $docblock->getLongDescription()->getFormattedContents() ), + 'description' => preg_replace( '/[\n\r]+/', ' ', $short_description ), + 'long_description' => format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) ), 'tags' => array(), ); + if ( ! empty( $code_snippets ) ) { + $output['code_snippets'] = $code_snippets; + } + if ( ! empty( $setup_blueprints ) ) { + $output['setup_blueprints'] = $setup_blueprints; + } + + $fenced_docblock_tag_counts = array_count_values( $fenced_docblock_tag_names ); foreach ( $docblock->getTags() as $tag ) { + if ( ! empty( $fenced_docblock_tag_counts[ $tag->getName() ] ) ) { + $fenced_docblock_tag_counts[ $tag->getName() ]--; + continue; + } + $tag_data = array( 'name' => $tag->getName(), 'content' => preg_replace( '/[\n\r]+/', ' ', format_description( $tag->getDescription() ) ), @@ -246,12 +511,115 @@ function export_docblock( $element ) { return $output; } +/** + * Returns a parser-safe DocBlock with complete fence bodies replaced by blanks. + * + * phpDocumentor does not recognize Markdown fences. It can mistake a fenced + * `@unlink(...)` call for a DocBlock tag, then reject a later expression such as + * `@! file_exists(...)` as a malformed tag. For example, these comment lines: + * + * * ```php + * * @unlink( '/tmp/example' ); + * * @! file_exists( '/tmp/example' ); + * * ``` + * * @since 1.0.0 + * + * are returned as: + * + * * ```php + * * + * * + * * ``` + * * @since 1.0.0 + * + * The fence delimiters and physical line count remain. Decorated body lines + * retain their indentation and `*`, and tags outside fences remain unchanged. + * phpDocumentor can therefore parse the real `@since` tag, while callers read + * descriptions and snippets from the original DocBlock. Keeping the fence + * delimiters also tells export_docblock() to recover that original source. + * + * An unmatched outer fence is not blanked because its body boundary is unknown. + * + * @param string $source_docblock Raw DocBlock including comment delimiters. + * + * @return string Parser-safe DocBlock, or the unchanged input when it contains + * no complete fence. + */ +function sanitize_docblock_fenced_contents( $source_docblock ) { + $original_source_docblock = $source_docblock; + + // Normalize line endings so tokenizer indexes map to physical source lines. + $source_docblock = preg_replace( "/\r\n?/", "\n", $source_docblock ); + + // Remove the opener and at most one decorative whitespace byte. + $contents = preg_replace( '/\A[ \t]*\/\*\*[ \t]?/', '', $source_docblock ); + + // Remove only the end-anchored closing delimiter and its indentation. + $contents = preg_replace( '/[ \t]*\*\/[ \t]*\z/', '', $contents ); + $content_lines = explode( "\n", $contents ); + foreach ( $content_lines as $key => $line ) { + // Remove the decorative star and at most one following whitespace byte. + $content_lines[ $key ] = preg_replace( '/^[ \t]*\*[ \t]?/', '', $line ); + } + + $fences = tokenize_docblock_code_fences( implode( "\n", $content_lines ) ); + if ( empty( $fences ) ) { + return $original_source_docblock; + } + + $source_lines = explode( "\n", $source_docblock ); + foreach ( $fences as $fence ) { + for ( $line = $fence['start'] + 1; $line < $fence['end']; $line++ ) { + // Retain indentation and the decorative star while blanking body text. + $source_lines[ $line ] = preg_match( '/^([ \t]*\*)/', $source_lines[ $line ], $prefix ) ? $prefix[1] : ''; + } + } + + return implode( "\n", $source_lines ); +} + +/** + * Describes the source DocBlock that contains invalid snippet metadata. + * + * @param BaseReflector|ReflectionAbstract $element + * @param \phpDocumentor\Reflection\DocBlock $docblock + * @param string $source_file Optional source path. + * + * @return string + */ +function describe_docblock_source( $element, $docblock, $source_file = '' ) { + if ( $element instanceof File_Reflector ) { + $entity = 'file'; + } elseif ( $element instanceof Hook_Reflector ) { + $entity = 'hook "' . $element->getName() . '"'; + } elseif ( $element instanceof PropertyReflector ) { + $entity = 'property "' . $element->getName() . '"'; + } elseif ( $element instanceof MethodReflector ) { + $entity = 'method "' . $element->getShortName() . '"'; + } elseif ( $element instanceof FunctionReflector ) { + $entity = 'function "' . $element->getShortName() . '"'; + } elseif ( $element instanceof ClassReflector ) { + $entity = 'class "' . $element->getShortName() . '"'; + } else { + $entity = 'element'; + } + + $source = '' !== $source_file ? ' in ' . $source_file : ''; + if ( $docblock->getLocation() && $docblock->getLocation()->getLineNumber() ) { + $source .= ' starting on source line ' . $docblock->getLocation()->getLineNumber(); + } + + return 'DocBlock for ' . $entity . $source; +} + /** * @param Hook_Reflector[] $hooks + * @param array $inherited_setup_blueprints Optional. Setup Blueprints inherited from the enclosing file or class. + * @param string $source_file Optional. Source path used in invalid snippet metadata errors. * * @return array */ -function export_hooks( array $hooks ) { +function export_hooks( array $hooks, array $inherited_setup_blueprints = array(), $source_file = '' ) { $out = array(); foreach ( $hooks as $hook ) { @@ -261,7 +629,7 @@ function export_hooks( array $hooks ) { 'end_line' => $hook->getNode()->getAttribute( 'endLine' ), 'type' => $hook->getType(), 'arguments' => $hook->getArgs(), - 'doc' => export_docblock( $hook ), + 'doc' => export_docblock( $hook, $inherited_setup_blueprints, $source_file ), ); } @@ -289,10 +657,12 @@ function export_arguments( array $arguments ) { /** * @param PropertyReflector[] $properties + * @param array $inherited_setup_blueprints Optional. Setup Blueprints inherited from the file or class DocBlock. + * @param string $source_file Optional. Source path used in invalid snippet metadata errors. * * @return array */ -function export_properties( array $properties ) { +function export_properties( array $properties, array $inherited_setup_blueprints = array(), $source_file = '' ) { $out = array(); foreach ( $properties as $property ) { @@ -304,7 +674,7 @@ function export_properties( array $properties ) { // 'final' => $property->isFinal(), 'static' => $property->isStatic(), 'visibility' => $property->getVisibility(), - 'doc' => export_docblock( $property ), + 'doc' => export_docblock( $property, $inherited_setup_blueprints, $source_file ), ); } @@ -313,10 +683,12 @@ function export_properties( array $properties ) { /** * @param MethodReflector[] $methods + * @param array $inherited_setup_blueprints Optional. Setup Blueprints inherited from the file or class DocBlock. + * @param string $source_file Optional. Source path used in invalid snippet metadata errors. * * @return array */ -function export_methods( array $methods ) { +function export_methods( array $methods, array $inherited_setup_blueprints = array(), $source_file = '' ) { $output = array(); foreach ( $methods as $method ) { @@ -332,14 +704,14 @@ function export_methods( array $methods ) { 'static' => $method->isStatic(), 'visibility' => $method->getVisibility(), 'arguments' => export_arguments( $method->getArguments() ), - 'doc' => export_docblock( $method ), + 'doc' => export_docblock( $method, $inherited_setup_blueprints, $source_file ), ); if ( ! empty( $method->uses ) ) { $method_data['uses'] = export_uses( $method->uses ); if ( ! empty( $method->uses['hooks'] ) ) { - $method_data['hooks'] = export_hooks( $method->uses['hooks'] ); + $method_data['hooks'] = export_hooks( $method->uses['hooks'], $inherited_setup_blueprints, $source_file ); } } @@ -349,6 +721,492 @@ function export_methods( array $methods ) { return $output; } +/** + * Returns Markdown-like backtick fences from a DocBlock's raw long description. + * + * @param string $text Raw DocBlock long description. + * + * @return array + */ +function get_docblock_code_fences( $text ) { + if ( preg_match( '//', $text ) ) { + throw new \InvalidArgumentException( + 'The DocBlock placeholder comment syntax is reserved for generated snippet placement.' + ); + } + + $fences = tokenize_docblock_code_fences( $text ); + + foreach ( $fences as $key => $fence ) { + $info_parts = '' === $fence['info'] ? array() : preg_split( '/\s+/', $fence['info'] ); + + // Match the complete public grammar before validating any option value. + // Setup-looking text on a non-interactive PHP fence remains ordinary + // documentation and must not make an existing DocBlock fail to parse. + $referenced_setup = null; + $is_interactive_php = false; + if ( 'php' === $fence['language'] && isset( $info_parts[1] ) && 'interactive' === $info_parts[1] ) { + if ( 2 === count( $info_parts ) ) { + $is_interactive_php = true; + } elseif ( 3 === count( $info_parts ) && 0 === strpos( $info_parts[2], 'setup-blueprint=' ) ) { + $referenced_setup = substr( $info_parts[2], strlen( 'setup-blueprint=' ) ); + validate_docblock_setup_blueprint_name( $referenced_setup, $fence['start'] ); + $is_interactive_php = true; + } + } + + $setup_name = null; + if ( 'setup-blueprint' === $fence['language'] && 2 === count( $info_parts ) ) { + $setup_name = $info_parts[1]; + validate_docblock_setup_blueprint_name( $setup_name, $fence['start'] ); + } + + $is_expected_output = 'expected-output' === $fence['language'] && 1 === count( $info_parts ); + $is_blueprint = 'setup-blueprint' === $fence['language'] && 1 === count( $info_parts ); + $fences[ $key ]['referenced_setup'] = $referenced_setup; + $fences[ $key ]['is_interactive_php'] = $is_interactive_php; + $fences[ $key ]['is_expected_output'] = $is_expected_output; + $fences[ $key ]['is_blueprint'] = $is_blueprint; + $fences[ $key ]['setup_name'] = $setup_name; + $fences[ $key ]['is_code_snippet'] = $is_interactive_php || $is_expected_output || $is_blueprint || null !== $setup_name; + } + + // Number the interactive PHP fences so the exporter and the stripper agree on each + // snippet's index without counting independently. + $snippet_index = 0; + foreach ( $fences as $key => $fence ) { + $fences[ $key ]['snippet_index'] = $fence['is_interactive_php'] ? $snippet_index++ : null; + } + + return $fences; +} + +/** + * Tokenizes complete backtick fences without interpreting their info strings. + * + * The raw-source recovery path needs fence boundaries before phpDocumentor has + * successfully parsed the comment. Keeping that lexical pass separate prevents + * invalid snippet metadata from escaping before export_docblock() can add source + * context to the resulting error. + * + * @param string $text Raw DocBlock contents or long description. + * + * @return array + */ +function tokenize_docblock_code_fences( $text ) { + $text = preg_replace( "/\r\n?/", "\n", $text ); + $lines = explode( "\n", $text ); + $line_count = count( $lines ); + $fences = array(); + + // Advance the outer cursor to each matching closer. Every line is examined + // at most once, and matching does not depend on PCRE recursion or JIT stack + // size. An opener without a matching closer stops parsing so later fence-like + // lines remain part of that unterminated block. + for ( $line_no = 0; $line_no < $line_count; $line_no++ ) { + if ( ! preg_match( '/^([ \t]*)(`{3,})([^`]*)$/', $lines[ $line_no ], $opening ) ) { + continue; + } + + $indent = $opening[1]; + $backticks = $opening[2]; + $closing_pattern = '/^[ \t]*' . preg_quote( $backticks, '/' ) . '[ \t]*$/'; + $end = $line_no + 1; + + while ( $end < $line_count && ! preg_match( $closing_pattern, $lines[ $end ] ) ) { + $end++; + } + + if ( $end === $line_count ) { + break; + } + + $code_lines = array_slice( $lines, $line_no + 1, $end - $line_no - 1 ); + if ( '' !== $indent ) { + // Content may be less indented than its fence, so remove as much of the + // opening prefix as each line repeats. Stop where tabs and spaces differ + // rather than guessing that unlike whitespace occupies equal columns. + foreach ( $code_lines as $key => $code_line ) { + $remove_length = 0; + $max_length = min( strlen( $indent ), strlen( $code_line ) ); + while ( $remove_length < $max_length && $indent[ $remove_length ] === $code_line[ $remove_length ] ) { + $remove_length++; + } + + if ( 0 < $remove_length ) { + $code_lines[ $key ] = substr( $code_line, $remove_length ); + } + } + } + + $info = trim( $opening[3] ); + $language = $info; + if ( preg_match( '/^\S+/', $language, $language_matches ) ) { + $language = $language_matches[0]; + } + + $fence = array( + 'language' => $language, + 'info' => $info, + 'code' => rtrim( implode( "\n", $code_lines ), "\n" ), + 'start' => $line_no, + 'end' => $end, + ); + + $fences[] = $fence; + + $line_no = $end; + } + + return $fences; +} + +/** + * Extract PHP fences marked `interactive` from a DocBlock's raw long description. + * + * Backtick fences may be indented in DocBlocks or nested Markdown lists. The + * closing fence must use the same number of backticks as the opener so + * different-length fences can appear inside a fenced snippet. Blueprint fences + * before a PHP fence apply to that fence, while immediately following metadata + * fences apply to the preceding PHP fence. Named setup Blueprint fences are + * exported once and snippets refer to them by name. Fence info words are + * case-sensitive so the documented lowercase forms are the only accepted syntax. + * Reusable setup Blueprint names use lowercase kebab-case starting with a letter. + * + * @param string $text Raw DocBlock long description. + * @param array $setup_blueprints Optional. Named setup Blueprints keyed by reference name. + * @param array|null $fences Optional. Fences already parsed from the same description. + * + * @throws \InvalidArgumentException When snippet metadata is invalid, ambiguous, or unattached. + * + * @return array + */ +function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fences = null ) { + if ( null === $fences ) { + $fences = get_docblock_code_fences( $text ); + } + $lines = explode( "\n", preg_replace( "/\r\n?/", "\n", $text ) ); + $snippets = array(); + + $pending_blueprint = null; + $pending_blueprint_fence = null; + $consumed_fences = array(); + $fence_count = count( $fences ); + $setup_blueprints = array(); + $setup_blueprint_lines = array(); + + foreach ( $fences as $fence ) { + if ( null !== $fence['setup_name'] ) { + if ( array_key_exists( $fence['setup_name'], $setup_blueprints ) ) { + throw new \InvalidArgumentException( + 'Setup Blueprint "' . $fence['setup_name'] . '" is defined more than once on lines ' . + $setup_blueprint_lines[ $fence['setup_name'] ] . ' and ' . ( $fence['start'] + 1 ) . + ' of the long description.' + ); + } + + $setup_blueprints[ $fence['setup_name'] ] = decode_docblock_blueprint( $fence['code'], $fence ); + $setup_blueprint_lines[ $fence['setup_name'] ] = $fence['start'] + 1; + } + } + + for ( $i = 0; $i < $fence_count; $i++ ) { + if ( isset( $consumed_fences[ $i ] ) ) { + continue; + } + + if ( null !== $fences[ $i ]['setup_name'] ) { + continue; + } + + if ( $fences[ $i ]['is_blueprint'] ) { + if ( + null !== $pending_blueprint && + docblock_fences_have_only_whitespace_between( $fences[ $pending_blueprint_fence ], $fences[ $i ], $lines ) + ) { + throw new \InvalidArgumentException( + 'Interactive snippet has more than one setup Blueprint: fences on lines ' . + ( $fences[ $pending_blueprint_fence ]['start'] + 1 ) . ' and ' . ( $fences[ $i ]['start'] + 1 ) . + ' of the long description cannot both precede one snippet.' + ); + } + + $pending_blueprint = decode_docblock_blueprint( $fences[ $i ]['code'], $fences[ $i ] ); + $pending_blueprint_fence = $i; + continue; + } + + if ( ! $fences[ $i ]['is_interactive_php'] ) { + $pending_blueprint = null; + $pending_blueprint_fence = null; + continue; + } + + $snippet = array( + 'type' => 'php-code-snippet', + 'code' => $fences[ $i ]['code'], + ); + + if ( null !== $fences[ $i ]['referenced_setup'] ) { + $snippet['blueprint'] = $fences[ $i ]['referenced_setup']; + } + + if ( + null !== $pending_blueprint && + docblock_fences_have_only_whitespace_between( $fences[ $pending_blueprint_fence ], $fences[ $i ], $lines ) + ) { + // A snippet accepts one setup source. Failing here prevents a named + // reference from silently overriding an adjacent inline Blueprint. + if ( array_key_exists( 'blueprint', $snippet ) ) { + throw new \InvalidArgumentException( + 'Interactive PHP fence on line ' . ( $fences[ $i ]['start'] + 1 ) . + ' of the long description has more than one setup Blueprint.' + ); + } + $snippet['blueprint'] = $pending_blueprint; + $consumed_fences[ $pending_blueprint_fence ] = true; + } + $pending_blueprint = null; + $pending_blueprint_fence = null; + + $previous_fence = $i; + for ( $j = $i + 1; $j < $fence_count; $j++ ) { + if ( ! docblock_fences_have_only_whitespace_between( $fences[ $previous_fence ], $fences[ $j ], $lines ) ) { + break; + } + + if ( $fences[ $j ]['is_interactive_php'] ) { + break; + } + + if ( $fences[ $j ]['is_expected_output'] ) { + // First expected-output fence ends the run, so a snippet takes one. + $snippet['expected_output'] = $fences[ $j ]['code']; + $consumed_fences[ $j ] = true; + break; + } + + if ( null !== $fences[ $j ]['setup_name'] ) { + break; + } + + if ( $fences[ $j ]['is_blueprint'] ) { + if ( array_key_exists( 'blueprint', $snippet ) ) { + throw new \InvalidArgumentException( + 'Interactive PHP fence on line ' . ( $fences[ $i ]['start'] + 1 ) . + ' of the long description has more than one setup Blueprint.' + ); + } + + $snippet['blueprint'] = decode_docblock_blueprint( $fences[ $j ]['code'], $fences[ $j ] ); + $consumed_fences[ $j ] = true; + $previous_fence = $j; + continue; + } + + break; + } + + $snippets[] = $snippet; + } + + // Recognized metadata is reserved for runnable snippets. Rejecting orphaned + // fences avoids removing author-written content without exporting it anywhere. + foreach ( $fences as $index => $fence ) { + if ( isset( $consumed_fences[ $index ] ) ) { + continue; + } + + if ( $fence['is_expected_output'] ) { + throw new \InvalidArgumentException( + 'Expected-output fence on line ' . ( $fence['start'] + 1 ) . + ' of the long description is not attached to an interactive PHP fence.' + ); + } + + if ( $fence['is_blueprint'] ) { + throw new \InvalidArgumentException( + 'Inline setup Blueprint on line ' . ( $fence['start'] + 1 ) . + ' of the long description is not attached to an interactive PHP fence.' + ); + } + } + + return $snippets; +} + +/** + * Checks whether two fences are separated only by blank DocBlock lines. + * + * Metadata may be visually separated from its snippet by blank lines, but + * prose between them starts a new documentation section and ends the pairing. + * + * @param array $first Earlier parsed fence. + * @param array $second Later parsed fence. + * @param array $lines Normalized DocBlock long-description lines. + * + * @return bool + */ +function docblock_fences_have_only_whitespace_between( $first, $second, $lines ) { + for ( $line = $first['end'] + 1; $line < $second['start']; $line++ ) { + if ( '' !== trim( $lines[ $line ] ) ) { + return false; + } + } + + return true; +} + +/** + * Removes snippet and snippet-metadata fences from the rendered description. + * + * Once a PHP fence becomes structured `code_snippets` data, leaving the same + * fence in `long_description` would make the theme render both the raw Markdown + * code block and the runnable snippet. + * + * @param string $text Raw DocBlock long description. + * @param array|null $fences Optional. Classified fences already validated by the snippet exporter. + * + * @return string + */ +function strip_docblock_code_snippet_fences( $text, $fences = null ) { + $text = preg_replace( "/\r\n?/", "\n", $text ); + $lines = explode( "\n", $text ); + if ( null === $fences ) { + $fences = get_docblock_code_fences( $text ); + } + $remove_lines = array(); + $replace_lines = array(); + + foreach ( $fences as $fence ) { + if ( ! $fence['is_code_snippet'] ) { + continue; + } + + // Interactive PHP fences become `code_snippets` entries. A plain HTML + // comment survives Markdown rendering, `the_content`, and block parsing, + // allowing the theme to replace it in place between the surrounding prose. + // Snippet-metadata fences (expected-output, Blueprints) are removed. + for ( $i = $fence['start']; $i <= $fence['end']; $i++ ) { + if ( $fence['is_interactive_php'] && $i === $fence['start'] ) { + // Keep a nested fence's indentation so Markdown leaves the replacement + // inside its list item instead of closing the list around the snippet. + $indent = substr( $lines[ $i ], 0, strspn( $lines[ $i ], " \t" ) ); + $replace_lines[ $i ] = $indent . ''; + } else { + $remove_lines[ $i ] = true; + } + } + } + + foreach ( $lines as $line_number => $line ) { + if ( isset( $replace_lines[ $line_number ] ) ) { + $lines[ $line_number ] = $replace_lines[ $line_number ]; + } elseif ( isset( $remove_lines[ $line_number ] ) ) { + unset( $lines[ $line_number ] ); + } + } + + return trim( implode( "\n", $lines ) ); +} + +/** + * Decodes a Blueprint fence into the structure exported to JSON. + * + * @param string $blueprint Blueprint JSON. + * @param array $fence Optional. Parsed fence used to identify invalid input. + * + * @throws \InvalidArgumentException When the Blueprint is not a valid JSON object. + * + * @return array|\stdClass + */ +function decode_docblock_blueprint( $blueprint, $fence = null ) { + $decoded = json_decode( $blueprint ); + $label = 'Setup Blueprint'; + + if ( is_array( $fence ) ) { + if ( null !== $fence['setup_name'] ) { + $label .= ' "' . $fence['setup_name'] . '"'; + } + $label .= ' on line ' . ( $fence['start'] + 1 ) . ' of the long description'; + } + + if ( JSON_ERROR_NONE !== json_last_error() ) { + $error = function_exists( 'json_last_error_msg' ) ? json_last_error_msg() : 'error code ' . json_last_error(); + throw new \InvalidArgumentException( $label . ' must contain valid JSON: ' . $error ); + } + + if ( ! is_object( $decoded ) ) { + throw new \InvalidArgumentException( $label . ' must be a JSON object.' ); + } + + preserve_json_object_shapes( $decoded ); + return $decoded; +} + +/** + * Preserves JSON objects that associative decoding would turn into lists. + * + * Most JSON objects naturally become associative PHP arrays and serialize back + * as objects. Empty objects and objects with sequential numeric keys instead + * serialize as JSON lists unless they remain objects. Decoding as objects first + * supplies that distinction. This function converts ordinary named objects to + * the associative arrays expected by the importer while retaining objects that + * would change type when encoded again. + * + * @param mixed $value JSON value decoded as objects. + * + * @return mixed + */ +function preserve_json_object_shapes( &$value ) { + if ( is_object( $value ) ) { + $decoded = get_object_vars( $value ); + foreach ( $decoded as &$child ) { + preserve_json_object_shapes( $child ); + } + unset( $child ); + + if ( empty( $decoded ) || array_keys( $decoded ) === range( 0, count( $decoded ) - 1 ) ) { + $value = (object) $decoded; + } else { + $value = $decoded; + } + + return $value; + } + + if ( is_array( $value ) ) { + foreach ( $value as &$child ) { + preserve_json_object_shapes( $child ); + } + unset( $child ); + } + + return $value; +} + +/** + * Rejects reusable setup Blueprint names outside the documented kebab-case form. + * + * Requiring a leading letter prevents PHP from coercing a numeric name into an + * integer array key and changing the setup Blueprint map into a JSON list. + * + * @param string $name Setup Blueprint name. + * @param int $line_no Zero-based long-description line containing the name. + * + * @throws \InvalidArgumentException When the name is not lowercase kebab-case. + */ +function validate_docblock_setup_blueprint_name( $name, $line_no ) { + if ( preg_match( '/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/D', $name ) ) { + return; + } + + throw new \InvalidArgumentException( + 'Setup Blueprint name "' . $name . '" on line ' . ( $line_no + 1 ) . + ' of the long description must be lowercase kebab-case starting with a letter.' + ); +} + /** * Export the list of elements used by a file or structure. * @@ -408,6 +1266,59 @@ function export_uses( array $uses ) { return $out; } +/** + * Format the given long description with Markdown blocks. + * + * @param string $description Description. + * @return string Description as Markdown if the Parsedown class exists, otherwise return + * the given description text. + */ +function format_long_description( $description ) { + // Preserve phpDocumentor's established handling of plain HTML code blocks. + // The snippet parser works from raw contents because it must remove selected + // fences before Markdown rendering, bypassing getFormattedContents(). + if ( false !== strpos( $description, '' ) ) { + $description = str_replace( + array( '', "\r\n", "\n", "\r", '' ), + array( '
', '', '', '', '
' ), + $description + ); + } + + if ( class_exists( 'Parsedown' ) ) { + $parsedown = \Parsedown::instance(); + $description = $parsedown->text( $description ); + } + + /* + * Fences may use more than three leading spaces. Parsedown puts adjacent + * indented placeholders into one code block, including the blank lines left + * by removed metadata fences. Unwrap only a code block made entirely of our + * intermediate markers, then publish the final comments consumed by the theme. + * Keeping the intermediate name distinct also prevents author-written final + * comments from being mistaken for generated placement markers here. The + * whitespace runs are possessive because a marker begins with `&`, so a long + * indented near-match never needs whitespace backtracking. + */ + $description = preg_replace_callback( + '#
((?:[ \t\n]*+<!-- wp-parser-code-snippet-placeholder:[0-9]+ -->)+[ \t\n]*+)
#', + function ( $matches ) { + $placeholders = str_replace( array( '<', '>' ), array( '<', '>' ), trim( $matches[1] ) ); + return preg_replace( '/[ \t\n]++(?=/', + '', + $description + ); + + $description = fix_newlines( $description ); + + return $description; +} + /** * Format the given description with Markdown. * diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 05686f51..27624632 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -7,6 +7,18 @@ * fact, this one does. It spans more than two full lines, continuing on to the * third line. * + * ```setup-blueprint file-greeting + * { + * "steps": [ + * { + * "step": "writeFile", + * "path": "/wordpress/wp-content/mu-plugins/file-greeting.php", + * "data": "assertEquals( + "
first\nsecond\n
", + \WP_Parser\format_long_description( "\nfirst\nsecond\n" ) + ); + } + /** * Test that hooks which aren't documented don't receive docs from another node. */ @@ -42,7 +53,19 @@ public function test_hook_docblocks() { $this->assertHookHasDocs( 'test_action' - , array( 'description' => 'A test action.' ) + , array( + 'description' => 'A test action.', + 'long_description' => '', + 'code_snippets' => array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'Hello from the file setup', + 'blueprint' => 'file-greeting', + ), + ), + 'setup_blueprints' => $this->file_greeting_setup_blueprints(), + ) ); $this->assertHookHasDocs( @@ -67,7 +90,106 @@ public function test_hook_docblocks() { public function test_file_docblocks() { $this->assertFileHasDocs( - array( 'description' => 'This is the file-level docblock summary.' ) + array( + 'description' => 'This is the file-level docblock summary.', + 'setup_blueprints' => $this->file_greeting_setup_blueprints(), + ) + ); + } + + /** + * Test fences that occupy the first paragraph of a DocBlock. + */ + public function test_fence_first_docblocks() { + + $file = __DIR__ . '/fence-first-docblocks.inc'; + $parsed = \WP_Parser\parse_files( array( $file ), __DIR__ ); + $file = $parsed[0]; + + $this->assertSame( '', $file['file']['description'] ); + $this->assertSame( '', $file['file']['long_description'] ); + $this->assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'shared', + ), + ), + $file['file']['code_snippets'] + ); + $this->assertEquals( + array( + 'shared' => array( 'steps' => array() ), + ), + $file['file']['setup_blueprints'] + ); + + $this->assertSame( '', $file['functions'][0]['doc']['description'] ); + $this->assertSame( '', $file['functions'][0]['doc']['long_description'] ); + $this->assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'shared', + ), + ), + $file['functions'][0]['doc']['code_snippets'] + ); + + $this->assertSame( + "assertSame( + '', + $file['functions'][1]['doc']['long_description'] + ); + + $this->assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'at sign', + ), + ), + $file['functions'][2]['doc']['code_snippets'] + ); + $this->assertEquals( + array( + array( + 'name' => 'since', + 'content' => '1.0.0', + ), + array( + 'name' => '_before', + 'content' => 'A real custom tag.', + ), + array( + 'name' => '_same', + 'content' => 'Another real custom tag.', + ), + array( + 'name' => 'author', + 'content' => 'Jane Doe', + ), + ), + $file['functions'][2]['doc']['tags'] ); } @@ -131,6 +253,960 @@ public function test_method_docblocks() { ); } + /** + * Test that method code snippets are exported. + */ + public function test_method_code_snippets() { + + $this->assertMethodHasDocs( + 'Test_Class' + , 'test_method_with_code_snippet' + , array( + 'code_snippets' => array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'Hello from a method', + 'blueprint' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/wordpress/wp-content/mu-plugins/docs-fixture.php', + 'data' => " '

Use this example:

', + ) + ); + } + + /** + * Test that reusable setup Blueprints are exported once and referenced by snippets. + */ + public function test_method_reused_setup_blueprint() { + + $this->assertMethodHasDocs( + 'Test_Class' + , 'test_method_with_reused_setup_blueprint' + , array( + 'setup_blueprints' => array( + 'shared-greeting' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/wordpress/wp-content/mu-plugins/shared-greeting.php', + 'data' => " array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'Hello, first', + 'blueprint' => 'shared-greeting', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'Hello, second', + 'blueprint' => 'shared-greeting', + ), + ), + ) + ); + } + + /** + * Test that methods can reference setup Blueprints from the file DocBlock. + */ + public function test_method_file_setup_blueprint() { + + $this->assertMethodHasDocs( + 'Test_Class' + , 'test_method_with_file_setup_blueprint' + , array( + 'setup_blueprints' => $this->file_greeting_setup_blueprints(), + 'code_snippets' => array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'Hello from the file setup', + 'blueprint' => 'file-greeting', + ), + ), + 'long_description' => '', + ) + ); + } + + /** + * Test exact fence matching and indentation handling. + * + * @dataProvider code_snippet_fence_delimiters + */ + public function test_code_snippet_fence_delimiters( $description, $expected_code, $expected_output ) { + + $snippets = \WP_Parser\export_docblock_code_snippets( $description ); + + $this->assertCount( 1, $snippets ); + $this->assertSame( $expected_code, $snippets[0]['code'] ); + $this->assertSame( $expected_output, $snippets[0]['expected_output'] ); + } + + public function code_snippet_fence_delimiters() { + return array( + 'smaller runs stay inside a larger fence' => array( + "````php interactive\n array( + "```php interactive\n array( + " ```php interactive\n array( + " ```php interactive\nassertSame( array(), \WP_Parser\export_docblock_code_snippets( $description ) ); + $this->assertSame( $description, \WP_Parser\strip_docblock_code_snippet_fences( $description ) ); + } + + public function ignored_code_fence_boundaries() { + return array( + 'inline backticks' => array( 'Inline ```php interactive is not a fence.' ), + 'two backticks' => array( "``php interactive\n array( + "````php interactive\n array( + "```js\nconsole.log('complete');\n```\n\n````php interactive\nassertSame( $expected_path, $snippets[0]['blueprint']['steps'][0]['path'] ); + } + + public function inline_setup_blueprint_positions() { + $php = "```php interactive\n array( + "```setup-blueprint\n{\"steps\":[{\"step\":\"writeFile\",\"path\":\"/tmp/before.php\",\"data\":\"\"}]}\n```\n" . $php, + '/tmp/before.php', + ), + 'after PHP' => array( + $php . "\n```setup-blueprint\n{\"steps\":[{\"step\":\"writeFile\",\"path\":\"/tmp/after.php\",\"data\":\"\"}]}\n```", + '/tmp/after.php', + ), + ); + } + + /** + * Test that large valid fences do not depend on the PCRE JIT stack size. + */ + public function test_large_code_snippet_fence() { + + $line_count = 12000; + $description = "```php interactive\n" . str_repeat( "echo 'line';\n", $line_count ) . '```'; + $fences = \WP_Parser\get_docblock_code_fences( $description ); + + $this->assertCount( 1, $fences ); + $this->assertEquals( $line_count, substr_count( $fences[0]['code'], "echo 'line';" ) ); + $this->assertTrue( $fences[0]['is_interactive_php'] ); + } + + /** + * Test that trailing blank lines are not included in exported code. + */ + public function test_code_snippet_trims_trailing_blank_lines() { + + $fences = \WP_Parser\get_docblock_code_fences( + "```php interactive\nassertEquals( "assertEquals( + "assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => "assertSame( array(), \WP_Parser\export_docblock_code_snippets( $description ) ); + $this->assertSame( $description, \WP_Parser\strip_docblock_code_snippet_fences( $description ) ); + } + + public function unrecognized_code_fence_info_strings() { + $php = ' array( 'php', $php ), + 'interactive inside an option' => array( 'php title="interactive-is-not-the-first-argument"', $php ), + 'combined language name' => array( 'php-interactive', $php ), + 'option on non-interactive PHP' => array( 'php example setup-blueprint=NOT-VALID', $php ), + 'uppercase interactive marker' => array( 'php INTERACTIVE setup-blueprint=ALSO-INVALID', $php ), + 'Blueprint alias' => array( 'blueprint', $blueprint ), + 'collapsed setup Blueprint name' => array( 'setupblueprint shared', $blueprint ), + 'setup Blueprint after JSON language' => array( 'json setup-blueprint shared', $blueprint ), + 'Blueprint reference alias' => array( 'php interactive blueprint=shared', $php ), + 'collapsed Blueprint reference' => array( 'php interactive setupblueprint=shared', $php ), + 'unsupported interactive option' => array( 'php interactive editable=false', $php ), + 'output alias' => array( 'output', 'output' ), + 'underscored expected output' => array( 'expected_output', 'output' ), + 'typed expected output' => array( 'text/expected-output', 'output' ), + 'uppercase PHP' => array( 'PHP interactive', $php ), + 'mixed-case PHP' => array( 'Php interactive', $php ), + 'uppercase interactive marker without options' => array( 'php INTERACTIVE', $php ), + 'mixed-case interactive marker' => array( 'php Interactive', $php ), + 'uppercase setup option' => array( 'php interactive SETUP-BLUEPRINT=shared', $php ), + 'uppercase setup language' => array( 'SETUP-BLUEPRINT shared', $blueprint ), + 'mixed-case setup language' => array( 'Setup-Blueprint shared', $blueprint ), + 'uppercase expected output' => array( 'EXPECTED-OUTPUT', 'output' ), + 'mixed-case expected output' => array( 'Expected-Output', 'output' ), + ); + } + + /** + * Test that each PHP fence is replaced with an inline placeholder, in order, + * so the theme can render each snippet between the surrounding prose instead + * of collapsing every snippet to the end of the description. Snippet-metadata + * fences (expected-output, Blueprints) are removed. + */ + public function test_code_snippet_inline_placeholders() { + + $description = implode( + "\n", + array( + 'First prose.', + '', + '```php interactive', + '' ); + $second = strpos( $stripped, '' ); + $this->assertNotFalse( $first ); + $this->assertNotFalse( $second ); + $this->assertLessThan( $second, $first ); + $this->assertLessThan( $first, strpos( $stripped, 'First prose.' ) ); + $this->assertGreaterThan( $first, strpos( $stripped, 'Middle prose.' ) ); + $this->assertGreaterThan( $second, strpos( $stripped, 'Closing prose.' ) ); + + // No raw PHP fence or metadata fence is left behind in the description. + $this->assertStringNotContainsString( '```', $stripped ); + $this->assertStringNotContainsString( 'assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'php-code-snippet', + 'code' => " 'done', + ), + ), + \WP_Parser\export_docblock_code_snippets( $description ) + ); + } + + /** + * Test that a snippet placeholder remains inside its Markdown list item. + * + * @dataProvider markdown_list_code_snippet_indentation + */ + public function test_indented_code_snippet_placeholder_preserves_markdown_list( $marker, $indent, $expected ) { + + $description = implode( + "\n", + array( + $marker . ' Before', + '', + $indent . '```php interactive', + $indent . 'assertStringContainsString( $indent . '', $stripped ); + $this->assertSame( + $expected, + \WP_Parser\format_long_description( $stripped ) + ); + } + + /** + * Returns Markdown list markers and their content indentation. + */ + public function markdown_list_code_snippet_indentation() { + return array( + 'one-digit ordered marker' => array( + '1.', + ' ', + '
  1. Before

    After

', + ), + 'three-digit ordered marker' => array( + '100.', + ' ', + '
  1. Before

    After

', + ), + 'unordered marker' => array( + '-', + ' ', + '
  • Before

    After

', + ), + ); + } + + /** + * Test that arbitrary fence indentation does not turn a placeholder into code. + * + * @dataProvider standalone_code_snippet_indentation + */ + public function test_standalone_indented_code_snippet_placeholder_remains_html( $indent ) { + + $description = "Before\n\n" . + $indent . "```php interactive\n" . + $indent . "assertStringContainsString( $indent . '', $stripped ); + $this->assertSame( + '

Before

After

', + \WP_Parser\format_long_description( $stripped ) + ); + } + + /** + * Test adjacent, deeply indented placeholders do not merge into visible code. + */ + public function test_adjacent_indented_code_snippet_placeholders_remain_html() { + + $description = implode( + "\n", + array( + 'Before', + '', + ' ```php interactive', + ' assertSame( + '

Before

After

', + \WP_Parser\format_long_description( $stripped ) + ); + } + + /** + * Returns indentation that Markdown otherwise treats as a code block. + */ + public function standalone_code_snippet_indentation() { + return array( + 'four spaces' => array( ' ' ), + 'eight spaces' => array( ' ' ), + 'tab' => array( "\t" ), + 'two tabs' => array( "\t\t" ), + ); + } + + /** + * Test that author text cannot collide with generated snippet placeholders. + * + * @dataProvider reserved_code_snippet_placeholders + */ + public function test_reserved_code_snippet_placeholder_fails( $source ) { + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'is reserved for generated snippet placement' ); + + \WP_Parser\get_docblock_code_fences( + "Before\n\n" . $source . "\n\n```php interactive\n array( '' ), + 'indented public placeholder' => array( ' ' ), + 'intermediate placeholder' => array( '' ), + 'intermediate placeholder in an ordinary fence' => array( + "```\n\n```", + ), + ); + } + + /** + * Test that snippet metadata fences do not accept extra arguments. + */ + public function test_code_snippet_metadata_rejects_extra_arguments() { + + $this->assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => "expectException( \InvalidArgumentException::class ); + + \WP_Parser\export_docblock_code_snippets( + implode( + "\n", + array( + '```' . $fence_info, + $blueprint, + '```', + '```php interactive', + ' array( 'setup-blueprint', '{"steps":' ), + 'malformed named Blueprint' => array( 'setup-blueprint shared', '{"steps":' ), + 'plain text' => array( 'setup-blueprint', 'not-json' ), + 'JSON null' => array( 'setup-blueprint', 'null' ), + 'JSON string' => array( 'setup-blueprint', '"string"' ), + 'JSON number' => array( 'setup-blueprint', '42' ), + 'JSON list' => array( 'setup-blueprint', '[]' ), + 'trailing content' => array( 'setup-blueprint', '{"steps":[]} trailing' ), + ); + } + + /** + * Test that Blueprint objects retain their JSON type through export and import decoding. + * + * @dataProvider blueprint_object_shapes + */ + public function test_blueprint_json_object_shapes_are_preserved( $blueprint ) { + + $decoded = \WP_Parser\decode_docblock_blueprint( $blueprint ); + $exported = json_encode( $decoded ); + $imported = json_decode( $exported ); + \WP_Parser\preserve_json_object_shapes( $imported ); + + $this->assertSame( $blueprint, $exported ); + $this->assertSame( $blueprint, json_encode( $imported ) ); + } + + /** + * Returns Blueprint objects whose shape associative decoding would otherwise lose. + */ + public function blueprint_object_shapes() { + + return array( + 'empty Blueprint' => array( '{}' ), + 'nested empty object' => array( '{"constants":{},"steps":[]}' ), + 'numeric object keys' => array( '{"siteOptions":{"0":"zero","1":"one"},"steps":[]}' ), + ); + } + + /** + * Test that reusable setup Blueprint names use one unambiguous form. + * + * @dataProvider invalid_setup_blueprint_names + */ + public function test_invalid_setup_blueprint_name_fails( $fence_info, $contents ) { + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'must be lowercase kebab-case starting with a letter' ); + + \WP_Parser\export_docblock_code_snippets( + "```" . $fence_info . "\n" . $contents . "\n```" + ); + } + + /** + * Returns malformed reusable setup Blueprint definitions and references. + */ + public function invalid_setup_blueprint_names() { + + return array( + 'numeric definition' => array( 'setup-blueprint 0', '{}' ), + 'uppercase definition' => array( 'setup-blueprint Shared', '{}' ), + 'underscore definition' => array( 'setup-blueprint shared_name', '{}' ), + 'leading hyphen definition' => array( 'setup-blueprint -shared', '{}' ), + 'trailing hyphen definition' => array( 'setup-blueprint shared-', '{}' ), + 'repeated hyphen definition' => array( 'setup-blueprint shared--name', '{}' ), + 'dotted definition' => array( 'setup-blueprint shared.name', '{}' ), + 'numeric reference' => array( 'php interactive setup-blueprint=0', ' array( 'php interactive setup-blueprint=Shared', ' array( 'php interactive setup-blueprint=shared_name', ' array( 'php interactive setup-blueprint=', 'assertArrayHasKey( $name, $setup_blueprints ); + $this->assertSame( $name, $snippets[0]['blueprint'] ); + } + + /** + * Returns valid reusable setup Blueprint names. + */ + public function valid_setup_blueprint_names() { + + return array( + 'single letter' => array( 'a' ), + 'trailing number' => array( 'shared0' ), + 'hyphenated number' => array( 'shared-0' ), + ); + } + + /** + * Test that duplicate reusable setup Blueprint definitions fail instead of overwriting. + */ + public function test_duplicate_setup_blueprint_name_fails() { + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Setup Blueprint "shared" is defined more than once on lines 1 and 4 of the long description.' ); + + \WP_Parser\export_docblock_code_snippets( + implode( + "\n", + array( + '```setup-blueprint shared', + '{"steps":[]}', + '```', + '```setup-blueprint shared', + '{"constants":{}}', + '```', + ) + ) + ); + } + + /** + * Test that a local setup Blueprint cannot silently replace an inherited definition. + */ + public function test_setup_blueprint_name_cannot_shadow_inherited_definition() { + + $file = __DIR__ . '/shadowed-blueprint.inc'; + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( + 'DocBlock for class "Shadowed_Blueprint_Example" in shadowed-blueprint.inc starting on source line 11: ' . + 'Setup Blueprint "shared" on line 1 of the long description is already defined in an enclosing DocBlock.' + ); + + \WP_Parser\parse_files( array( $file ), __DIR__ ); + } + + /** + * Test that invalid Blueprint failures identify the definition location. + */ + public function test_invalid_setup_blueprint_error_identifies_fence() { + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Setup Blueprint "shared" on line 2 of the long description must contain valid JSON' ); + + \WP_Parser\export_docblock_code_snippets( + implode( + "\n", + array( + 'Introductory prose.', + '```setup-blueprint shared', + '{"steps":', + '```', + ) + ) + ); + } + + /** + * Test that one snippet cannot silently choose between multiple setup Blueprints. + * + * @dataProvider ambiguous_setup_blueprints + */ + public function test_ambiguous_setup_blueprints_fail( $description ) { + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'more than one setup Blueprint' ); + + \WP_Parser\export_docblock_code_snippets( $description ); + } + + public function ambiguous_setup_blueprints() { + + $inline = "```setup-blueprint\n{}\n```"; + $named = "```php interactive setup-blueprint=shared\n array( $inline . "\n" . $named ), + 'inline after named reference' => array( $named . "\n" . $inline ), + 'two inline Blueprints before PHP' => array( $inline . "\n" . $inline . "\n" . $plain ), + 'two inline Blueprints after PHP' => array( $plain . "\n" . $inline . "\n" . $inline ), + ); + } + + /** + * Test that Blueprint failures identify their source file and entity. + * + * @dataProvider invalid_blueprint_source_files + */ + public function test_blueprint_error_identifies_source( $fixture, $entity, $error ) { + + $file = __DIR__ . '/' . $fixture; + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'DocBlock for function "' . $entity . '" in ' . $fixture . ' starting on source line 3: ' . $error ); + + \WP_Parser\parse_files( array( $file ), __DIR__ ); + } + + /** + * Returns malformed and unresolved Blueprint fixture errors. + */ + public function invalid_blueprint_source_files() { + + return array( + 'invalid JSON' => array( + 'invalid-blueprint.inc', + 'invalid_blueprint_example', + 'Setup Blueprint "broken" on line 1 of the long description must contain valid JSON', + ), + 'undefined reference' => array( + 'undefined-blueprint.inc', + 'undefined_blueprint_example', + 'Setup Blueprint "missing" referenced on line 1 of the long description is not defined.', + ), + ); + } + + /** + * Test that metadata after expected output belongs to the next snippet. + */ + public function test_code_snippet_metadata_boundaries() { + + $this->assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'First', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/tmp/second.php', + 'data' => 'expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'is not attached to an interactive PHP fence' ); + + \WP_Parser\export_docblock_code_snippets( $description ); + } + + public function unattached_snippet_metadata() { + + $php = "```php interactive\n array( "```expected-output\n1\n```\n" . $php ), + 'expected output after prose' => array( $php . "\nProse.\n```expected-output\n1\n```" ), + 'inline Blueprint before prose' => array( "```setup-blueprint\n{}\n```\nProse.\n" . $php ), + 'inline Blueprint after prose' => array( $php . "\nProse.\n```setup-blueprint\n{}\n```" ), + 'duplicate expected output' => array( $php . "\n```expected-output\n1\n```\n```expected-output\n2\n```" ), + ); + } + + /** + * Test named setup Blueprint definitions and references. + */ + public function test_code_snippet_named_setup_blueprints() { + + $setup_blueprints = array(); + $snippets = \WP_Parser\export_docblock_code_snippets( + implode( + "\n", + array( + '```setup-blueprint shared', + '{"steps":[{"step":"writeFile","path":"/tmp/shared.php","data":"assertEquals( + array( + 'shared' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/tmp/shared.php', + 'data' => 'assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'first', + 'blueprint' => 'shared', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'no leaked inline blueprint', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'shared', + ), + ), + $snippets + ); + } + /** * Test that function docs are exported. */ @@ -139,7 +1215,45 @@ public function test_property_docblocks() { $this->assertPropertyHasDocs( 'Test_Class' , '$a_string' - , array( 'description' => 'This is a docblock for a class property.' ) + , array( + 'description' => 'This is a docblock for a class property.', + 'long_description' => '', + 'code_snippets' => array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'Hello from the file setup', + 'blueprint' => 'file-greeting', + ), + ), + 'tags' => array( + array( + 'name' => 'since', + 'content' => '3.0.0', + ), + array( + 'name' => 'var', + 'content' => '', + 'types' => array( 'string' ), + 'variable' => '', + ), + ), + 'setup_blueprints' => $this->file_greeting_setup_blueprints(), + ) + ); + } + + private function file_greeting_setup_blueprints() { + return array( + 'file-greeting' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/wordpress/wp-content/mu-plugins/file-greeting.php', + 'data' => "write_json_file( $json ); + + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'must contain a top-level list of parsed files' ); + + $command = new Command_Import_Test_Command; + $command->import( array( $file ), array() ); + } + + public function invalid_top_level_json_values() { + return array( + 'empty object' => array( '{}' ), + 'non-empty object' => array( '{"path":"wp-includes/version.php"}' ), + 'null' => array( 'null' ), + 'string' => array( '"parsed files"' ), + 'number' => array( '42' ), + 'boolean' => array( 'false' ), + ); + } + + public function test_import_rejects_malformed_json() { + $file = $this->write_json_file( '[}' ); + + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( "can't be decoded" ); + + $command = new Command_Import_Test_Command; + $command->import( array( $file ), array() ); + } + + /** + * @dataProvider invalid_file_entries + */ + public function test_import_rejects_invalid_file_entries( $json ) { + $file = $this->write_json_file( $json ); + + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'entry 1 must contain a parsed file object with a path and file metadata' ); + + $command = new Command_Import_Test_Command; + $command->import( array( $file ), array() ); + } + + public function invalid_file_entries() { + return array( + 'null' => array( '[null]' ), + 'number' => array( '[42]' ), + 'string' => array( '["parsed file"]' ), + 'list' => array( '[[]]' ), + 'empty object' => array( '[{}]' ), + 'missing file metadata' => array( '[{"path":"example.php"}]' ), + 'non-string path' => array( '[{"path":42,"file":{"description":"","long_description":"","tags":[]}}]' ), + 'empty path' => array( '[{"path":"","file":{"description":"","long_description":"","tags":[]}}]' ), + 'non-object file metadata' => array( '[{"path":"example.php","file":[]}]' ), + 'empty object file metadata' => array( '[{"path":"example.php","file":{}}]' ), + 'missing description' => array( '[{"path":"example.php","file":{"long_description":"","tags":[]}}]' ), + 'non-string description' => array( '[{"path":"example.php","file":{"description":42,"long_description":"","tags":[]}}]' ), + 'missing long description' => array( '[{"path":"example.php","file":{"description":"","tags":[]}}]' ), + 'non-string long description' => array( '[{"path":"example.php","file":{"description":"","long_description":42,"tags":[]}}]' ), + 'missing tags' => array( '[{"path":"example.php","file":{"description":"","long_description":""}}]' ), + 'non-array tags' => array( '[{"path":"example.php","file":{"description":"","long_description":"","tags":42}}]' ), + 'empty object tags' => array( '[{"path":"example.php","file":{"description":"","long_description":"","tags":{}}}]' ), + 'named object tags' => array( '[{"path":"example.php","file":{"description":"","long_description":"","tags":{"name":"since","content":"1.0"}}}]' ), + 'numeric-key object tags' => array( '[{"path":"example.php","file":{"description":"","long_description":"","tags":{"0":{"name":"since","content":"1.0"}}}}]' ), + ); + } + + public function test_import_accepts_a_top_level_file_list() { + $file = $this->write_json_file( '[]' ); + $command = new Command_Import_Test_Command; + + $command->import( array( $file ), array() ); + + $this->assertSame( array(), $command->imported_data ); + } + + public function test_import_accepts_a_parsed_file_entry() { + $file = $this->write_json_file( '[{"file":{"description":"","long_description":"","tags":[{"name":"since","content":"1.0"}]},"path":"example.php","root":"/tmp"}]' ); + $command = new Command_Import_Test_Command; + + $command->import( array( $file ), array() ); + + $this->assertSame( + array( + array( + 'file' => array( + 'description' => '', + 'long_description' => '', + 'tags' => array( + array( + 'name' => 'since', + 'content' => '1.0', + ), + ), + ), + 'path' => 'example.php', + 'root' => '/tmp', + ), + ), + $command->imported_data + ); + } + + private function write_json_file( $json ) { + $file = tempnam( sys_get_temp_dir(), 'phpdoc-parser-' ); + file_put_contents( $file, $json ); + $this->files[] = $file; + + return $file; + } + + public function tearDown() { + foreach ( $this->files as $file ) { + unlink( $file ); + } + + parent::tearDown(); + } +} + +class Command_Import_Test_Command extends \WP_Parser\Command { + + public $imported_data; + + protected function _do_import( array $data, $skip_sleep = false, $import_ignored = false ) { + $this->imported_data = $data; + } +} diff --git a/tests/phpunit/tests/import/file.php b/tests/phpunit/tests/import/file.php index 5b073d74..2b558909 100644 --- a/tests/phpunit/tests/import/file.php +++ b/tests/phpunit/tests/import/file.php @@ -135,4 +135,82 @@ public function test_function_post_created() { , get_post_meta( $post->ID, '_wp-parser_tags', true ) ); } + + /** + * Test that snippet metadata is stored and cleared on later imports. + */ + public function test_function_snippet_metadata_imported_and_cleared() { + + $posts = get_posts( + array( 'post_type' => $this->importer->post_type_function ) + ); + $post = $posts[0]; + + $function_data = $this->export_data['functions'][0]; + $snippets = array( + array( + 'type' => 'php-code-snippet', + 'code' => ' 'shared', + ), + ); + $setup_blueprints = array( + 'shared' => array( 'steps' => array() ), + ); + $function_data['doc']['code_snippets'] = $snippets; + $function_data['doc']['setup_blueprints'] = $setup_blueprints; + + $this->importer->import_function( $function_data ); + + $this->assertEquals( $snippets, get_post_meta( $post->ID, '_wp-parser_code_snippets', true ) ); + $this->assertEquals( $setup_blueprints, get_post_meta( $post->ID, '_wp-parser_setup_blueprints', true ) ); + + unset( $function_data['doc']['code_snippets'], $function_data['doc']['setup_blueprints'] ); + $this->importer->import_function( $function_data ); + + $this->assertEquals( array(), get_post_meta( $post->ID, '_wp-parser_code_snippets', true ) ); + $this->assertEquals( array(), get_post_meta( $post->ID, '_wp-parser_setup_blueprints', true ) ); + } + + /** + * Test that WordPress metadata slashing does not alter snippet or Blueprint source. + */ + public function test_function_snippet_metadata_preserves_backslashes() { + + $posts = get_posts( + array( 'post_type' => $this->importer->post_type_function ) + ); + $post = $posts[0]; + + $function_data = $this->export_data['functions'][0]; + $snippets = array( + array( + 'type' => 'php-code-snippet', + 'code' => ' 'Docs\Example', + 'blueprint' => 'shared', + ), + ); + $setup_blueprints = array( + 'shared' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/wordpress/wp-content/mu-plugins/setup.php', + 'data' => ' (object) array( + '0' => 'C:\temporary\file.php', + ), + ), + ); + $function_data['doc']['code_snippets'] = $snippets; + $function_data['doc']['setup_blueprints'] = $setup_blueprints; + + $this->importer->import_function( $function_data ); + + $this->assertEquals( $snippets, get_post_meta( $post->ID, '_wp-parser_code_snippets', true ) ); + $this->assertEquals( $setup_blueprints, get_post_meta( $post->ID, '_wp-parser_setup_blueprints', true ) ); + } }