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-importer.php b/lib/class-importer.php index bc723723..44ad563e 100644 --- a/lib/class-importer.php +++ b/lib/class-importer.php @@ -760,6 +760,8 @@ 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'] ); + $anything_updated[] = update_post_meta( $post_id, '_wp-parser_code_snippets', $data['doc']['code_snippets'] ?? array() ); + $anything_updated[] = update_post_meta( $post_id, '_wp-parser_setup_blueprints', $data['doc']['setup_blueprints'] ?? array() ); $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..db20f8ba 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -55,9 +55,12 @@ function parse_files( $files, $root ) { $file->process(); + $file_doc = export_docblock( $file ); + $file_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, ); @@ -94,7 +97,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 ), 'hooks' => array(), ); @@ -110,6 +113,9 @@ function parse_files( $files, $root ) { } foreach ( $file->getClasses() as $class ) { + $class_doc = export_docblock( $class, $file_setup_blueprints ); + $class_setup_blueprints = array_merge( $file_setup_blueprints, $class_doc['setup_blueprints'] ?? array() ); + $class_data = array( 'name' => $class->getShortName(), 'namespace' => $class->getNamespace(), @@ -120,8 +126,8 @@ function parse_files( $files, $root ) { 'extends' => $class->getParentClass(), 'implements' => $class->getInterfaces(), 'properties' => export_properties( $class->getProperties() ), - 'methods' => export_methods( $class->getMethods() ), - 'doc' => export_docblock( $class ), + 'methods' => export_methods( $class->getMethods(), $class_setup_blueprints ), + 'doc' => $class_doc, ); $out['classes'][] = $class_data; @@ -190,10 +196,11 @@ function ( $matches ) use ( $replacement_string ) { /** * @param BaseReflector|ReflectionAbstract $element + * @param array $inherited_setup_blueprints Optional. Setup Blueprints inherited from the file or class DocBlock. * * @return array */ -function export_docblock( $element ) { +function export_docblock( $element, array $inherited_setup_blueprints = array() ) { $docblock = $element->getDocBlock(); if ( ! $docblock ) { return array( @@ -203,12 +210,27 @@ function export_docblock( $element ) { ); } + $raw_long_description = $docblock->getLongDescription()->getContents(); + $setup_blueprints = array(); + $code_snippets = export_docblock_code_snippets( $raw_long_description, $setup_blueprints ); + $setup_blueprints = array_merge( + get_referenced_setup_blueprints( $code_snippets, $inherited_setup_blueprints ), + $setup_blueprints + ); + $output = array( 'description' => preg_replace( '/[\n\r]+/', ' ', $docblock->getShortDescription() ), - 'long_description' => fix_newlines( $docblock->getLongDescription()->getFormattedContents() ), + 'long_description' => format_long_description( strip_docblock_code_snippet_fences( $raw_long_description ) ), 'tags' => array(), ); + if ( ! empty( $code_snippets ) ) { + $output['code_snippets'] = $code_snippets; + } + if ( ! empty( $setup_blueprints ) ) { + $output['setup_blueprints'] = $setup_blueprints; + } + foreach ( $docblock->getTags() as $tag ) { $tag_data = array( 'name' => $tag->getName(), @@ -313,10 +335,11 @@ function export_properties( array $properties ) { /** * @param MethodReflector[] $methods + * @param array $inherited_setup_blueprints Optional. Setup Blueprints inherited from the file or class DocBlock. * * @return array */ -function export_methods( array $methods ) { +function export_methods( array $methods, array $inherited_setup_blueprints = array() ) { $output = array(); foreach ( $methods as $method ) { @@ -332,7 +355,7 @@ 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 ), ); if ( ! empty( $method->uses ) ) { @@ -349,6 +372,338 @@ 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 ) { + $lines = explode( "\n", preg_replace( "/\r\n?/", "\n", $text ) ); + $fences = array(); + + for ( $i = 0, $line_count = count( $lines ); $i < $line_count; $i++ ) { + if ( ! preg_match( '/^([ \t]*)(`{3,})([^`]*)$/', $lines[ $i ], $opening ) ) { + continue; + } + + $indent = $opening[1]; + $fence = $opening[2]; + $language = trim( $opening[3] ); + $code_lines = array(); + $start_line = $i; + + for ( $j = $i + 1; $j < $line_count; $j++ ) { + // Match the exact opening fence so different-length fences stay in the snippet. + if ( preg_match( '/^[ \t]*' . preg_quote( $fence, '/' ) . '[ \t]*$/', $lines[ $j ] ) ) { + $i = $j; + break; + } + + if ( '' !== $indent && 0 === strpos( $lines[ $j ], $indent ) ) { + $code_lines[] = substr( $lines[ $j ], strlen( $indent ) ); + } else { + $code_lines[] = $lines[ $j ]; + } + } + + if ( $j === $line_count ) { + break; + } + + if ( preg_match( '/^\S+/', $language, $language_matches ) ) { + $language = $language_matches[0]; + } + + $fences[] = array( + 'language' => strtolower( $language ), + 'info' => trim( $opening[3] ), + 'code' => rtrim( implode( "\n", $code_lines ), "\n" ), + 'start' => $start_line, + 'end' => $i, + ); + } + + return $fences; +} + +/** + * Extract runnable PHP snippets 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. + * + * @param string $text Raw DocBlock long description. + * @param array $setup_blueprints Optional. Named setup Blueprints keyed by reference name. + * + * @return array + */ +function export_docblock_code_snippets( $text, &$setup_blueprints = null ) { + $fences = get_docblock_code_fences( $text ); + $snippets = array(); + + $pending_blueprint = null; + $consumed_fences = array(); + $fence_count = count( $fences ); + $setup_blueprints = array(); + + foreach ( $fences as $fence ) { + $setup_blueprint_name = get_docblock_setup_blueprint_name( $fence ); + if ( null !== $setup_blueprint_name ) { + $setup_blueprints[ $setup_blueprint_name ] = decode_docblock_blueprint( $fence['code'] ); + } + } + + for ( $i = 0; $i < $fence_count; $i++ ) { + if ( isset( $consumed_fences[ $i ] ) ) { + continue; + } + + if ( null !== get_docblock_setup_blueprint_name( $fences[ $i ] ) ) { + continue; + } + + if ( is_docblock_blueprint_fence( $fences[ $i ] ) ) { + $pending_blueprint = decode_docblock_blueprint( $fences[ $i ]['code'] ); + continue; + } + + if ( 'php' !== $fences[ $i ]['language'] ) { + $pending_blueprint = null; + continue; + } + + $snippet = array( + 'type' => 'php-code-snippet', + 'code' => $fences[ $i ]['code'], + ); + $has_expected_output = false; + + $referenced_blueprint_name = get_docblock_referenced_blueprint_name( $fences[ $i ] ); + if ( null !== $referenced_blueprint_name ) { + $snippet['blueprint'] = $referenced_blueprint_name; + } + + if ( null !== $pending_blueprint ) { + if ( ! array_key_exists( 'blueprint', $snippet ) ) { + $snippet['blueprint'] = $pending_blueprint; + } + $pending_blueprint = null; + } + + for ( $j = $i + 1; $j < $fence_count; $j++ ) { + if ( 'php' === $fences[ $j ]['language'] ) { + break; + } + + if ( is_docblock_expected_output_fence( $fences[ $j ] ) ) { + if ( ! $has_expected_output ) { + $snippet['expected_output'] = $fences[ $j ]['code']; + $has_expected_output = true; + $consumed_fences[ $j ] = true; + } + + break; + } + + if ( null !== get_docblock_setup_blueprint_name( $fences[ $j ] ) ) { + break; + } + + if ( is_docblock_blueprint_fence( $fences[ $j ] ) && ! array_key_exists( 'blueprint', $snippet ) ) { + $snippet['blueprint'] = decode_docblock_blueprint( $fences[ $j ]['code'] ); + $consumed_fences[ $j ] = true; + continue; + } + + break; + } + + $snippets[] = $snippet; + } + + return $snippets; +} + +/** + * 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. + * + * @return string + */ +function strip_docblock_code_snippet_fences( $text ) { + $text = preg_replace( "/\r\n?/", "\n", $text ); + $lines = explode( "\n", $text ); + $remove_lines = array(); + + foreach ( get_docblock_code_fences( $text ) as $fence ) { + if ( ! is_docblock_code_snippet_fence( $fence ) ) { + continue; + } + + for ( $i = $fence['start']; $i <= $fence['end']; $i++ ) { + $remove_lines[ $i ] = true; + } + } + + foreach ( $lines as $line_number => $line ) { + if ( isset( $remove_lines[ $line_number ] ) ) { + unset( $lines[ $line_number ] ); + } + } + + return trim( implode( "\n", $lines ) ); +} + +/** + * Returns inherited setup Blueprints referenced by the snippets. + * + * @param array $snippets Exported code snippets. + * @param array $setup_blueprints Setup Blueprints available from parent DocBlocks. + * + * @return array + */ +function get_referenced_setup_blueprints( $snippets, $setup_blueprints ) { + $referenced_setup_blueprints = array(); + + foreach ( $snippets as $snippet ) { + if ( ! is_string( $snippet['blueprint'] ?? null ) ) { + continue; + } + + if ( array_key_exists( $snippet['blueprint'], $setup_blueprints ) ) { + $referenced_setup_blueprints[ $snippet['blueprint'] ] = $setup_blueprints[ $snippet['blueprint'] ]; + } + } + + return $referenced_setup_blueprints; +} + +/** + * Checks whether a parsed DocBlock fence is represented by code snippet JSON. + * + * @param array $fence + * + * @return bool + */ +function is_docblock_code_snippet_fence( $fence ) { + return 'php' === $fence['language'] + || is_docblock_expected_output_fence( $fence ) + || is_docblock_blueprint_fence( $fence ) + || null !== get_docblock_setup_blueprint_name( $fence ); +} + +/** + * Checks whether a parsed DocBlock fence contains snippet expected output. + * + * @param array $fence + * + * @return bool + */ +function is_docblock_expected_output_fence( $fence ) { + return in_array( $fence['language'], array( 'expected-output', 'expected_output', 'output', 'text/expected-output' ), true ); +} + +/** + * Checks whether a parsed DocBlock fence contains a WordPress Playground Blueprint. + * + * @param array $fence + * + * @return bool + */ +function is_docblock_blueprint_fence( $fence ) { + if ( null !== get_docblock_setup_blueprint_name( $fence ) ) { + return false; + } + + $info = strtolower( $fence['info'] ); + + return in_array( $fence['language'], array( 'blueprint', 'setup-blueprint', 'setupblueprint' ), true ) + || ( 'json' === $fence['language'] && false !== strpos( ' ' . $info . ' ', ' blueprint ' ) ); +} + +/** + * Decodes a Blueprint fence into the structure exported to JSON. + * + * @param string $blueprint + * + * @return array|string + */ +function decode_docblock_blueprint( $blueprint ) { + $decoded = json_decode( $blueprint, true ); + + if ( is_array( $decoded ) ) { + return $decoded; + } + + return $blueprint; +} + +/** + * Returns the reference name for a reusable setup Blueprint fence. + * + * @param array $fence + * + * @return string|null + */ +function get_docblock_setup_blueprint_name( $fence ) { + $info_parts = get_docblock_fence_info_parts( $fence ); + + if ( in_array( $fence['language'], array( 'setup-blueprint', 'setupblueprint' ), true ) && isset( $info_parts[1] ) ) { + return $info_parts[1]; + } + + if ( 'json' === $fence['language'] && isset( $info_parts[1] ) && in_array( strtolower( $info_parts[1] ), array( 'setup-blueprint', 'setupblueprint' ), true ) && isset( $info_parts[2] ) ) { + return $info_parts[2]; + } + + return null; +} + +/** + * Returns the setup Blueprint reference from a PHP fence info string. + * + * @param array $fence + * + * @return string|null + */ +function get_docblock_referenced_blueprint_name( $fence ) { + foreach ( get_docblock_fence_info_parts( $fence ) as $part ) { + if ( preg_match( '/^(?:blueprint|setup-blueprint|setupblueprint)=(.+)$/i', $part, $matches ) ) { + return $matches[1]; + } + } + + return null; +} + +/** + * Splits the full fence info string into whitespace-delimited parts. + * + * @param array $fence + * + * @return array + */ +function get_docblock_fence_info_parts( $fence ) { + $info = trim( $fence['info'] ); + + if ( '' === $info ) { + return array(); + } + + return preg_split( '/\s+/', $info ); +} + /** * Export the list of elements used by a file or structure. * @@ -408,6 +763,24 @@ 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 ) { + if ( class_exists( 'Parsedown' ) ) { + $parsedown = \Parsedown::instance(); + $description = $parsedown->text( $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..4fedc1c5 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. * + * ```setupblueprint file-greeting + * { + * "steps": [ + * { + * "step": "writeFile", + * "path": "/wordpress/wp-content/mu-plugins/file-greeting.php", + * "data": "assertFileHasDocs( - array( 'description' => 'This is the file-level docblock summary.' ) + array( + 'description' => 'This is the file-level docblock summary.', + 'setup_blueprints' => array( + 'file-greeting' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/wordpress/wp-content/mu-plugins/file-greeting.php', + 'data' => "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' => array( + 'file-greeting' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/wordpress/wp-content/mu-plugins/file-greeting.php', + 'data' => " array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'Hello from the file setup', + 'blueprint' => 'file-greeting', + ), + ), + 'long_description' => '', + ) + ); + } + + /** + * Test tricky Markdown-like code fence parsing rules. + */ + public function test_code_snippet_fence_parser_edge_cases() { + + $description = implode( + "\n", + array( + 'Inline ```php is not a fence.', + '', + '``', + 'Two backticks are too short.', + '``', + '', + '````php title="outer.php"', + 'assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'outer', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'different-length', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'indented', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'three leading spaces', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'no blueprint from before JS', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'blueprint before', + 'blueprint' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/tmp/one.php', + 'data' => ' 'php-code-snippet', + 'code' => " 'blueprint after', + 'blueprint' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/tmp/two.php', + 'data' => 'assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => "assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'case fixture', + 'blueprint' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/tmp/case.php', + 'data' => 'assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => " 'not-json', + ), + ), + \WP_Parser\export_docblock_code_snippets( + implode( + "\n", + array( + '```blueprint', + 'not-json', + '```', + '```php', + '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' => 'assertEquals( + array( + 'shared' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/tmp/shared.php', + 'data' => ' array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/tmp/json-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' => " 'second', + 'blueprint' => 'json-shared', + ), + array( + 'type' => 'php-code-snippet', + 'code' => " 'shared', + ), + ), + $snippets + ); + } + /** * Test that function docs are exported. */