From 6374ab6e45d78de370ea93409ee3a0822ac45a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 7 Jun 2026 02:37:02 +0200 Subject: [PATCH 01/13] Export fenced PHP snippets with expected output --- lib/runner.php | 166 +++++++++++ tests/phpunit/tests/export/docblocks.inc | 32 ++ tests/phpunit/tests/export/docblocks.php | 353 +++++++++++++++++++++++ 3 files changed, 551 insertions(+) diff --git a/lib/runner.php b/lib/runner.php index ba3efdd4..3c4c0cda 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -335,6 +335,14 @@ function export_methods( array $methods ) { 'doc' => export_docblock( $method ), ); + $docblock = $method->getDocBlock(); + if ( $docblock ) { + $code_snippets = export_docblock_code_snippets( $docblock->getLongDescription()->getContents() ); + if ( ! empty( $code_snippets ) ) { + $method_data['doc']['code_snippets'] = $code_snippets; + } + } + if ( ! empty( $method->uses ) ) { $method_data['uses'] = export_uses( $method->uses ); @@ -349,6 +357,164 @@ function export_methods( array $methods ) { return $output; } +/** + * 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. + * + * @param string $text + * + * @return array + */ +function export_docblock_code_snippets( $text ) { + $lines = explode( "\n", preg_replace( "/\r\n?/", "\n", $text ) ); + $fences = array(); + $snippets = 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(); + + 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' => strtolower( trim( $opening[3] ) ), + 'code' => rtrim( implode( "\n", $code_lines ), "\n" ), + ); + } + + $pending_blueprint = null; + $consumed_fences = array(); + $fence_count = count( $fences ); + + for ( $i = 0; $i < $fence_count; $i++ ) { + if ( isset( $consumed_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'], + 'expected_output' => '', + ); + $has_expected_output = false; + + if ( null !== $pending_blueprint ) { + $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 ( 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; +} + +/** + * 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 ) { + return in_array( $fence['language'], array( 'blueprint', 'setup-blueprint' ), true ) + || ( 'json' === $fence['language'] && false !== strpos( ' ' . $fence['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; +} + /** * Export the list of elements used by a file or structure. * diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 05686f51..de073f08 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -61,6 +61,38 @@ class Test_Class { public function test_method( $var, $arr ) { return $var; } + + /** + * This is a method docblock with a code snippet. + * + * Use this example: + * + * ```blueprint + * { + * "steps": [ + * { + * "step": "writeFile", + * "path": "/wordpress/wp-content/mu-plugins/docs-fixture.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' => "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' => " '', + ), + ), + \WP_Parser\export_docblock_code_snippets( + implode( + "\n", + array( + '```php', + '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' => " '', + 'blueprint' => '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' => " '', + 'blueprint' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/tmp/second.php', + 'data' => ' Date: Mon, 8 Jun 2026 00:19:44 +0200 Subject: [PATCH 02/13] Reuse named setup Blueprints across snippets --- .github/workflows/unit-test.yml | 3 +- lib/class-importer.php | 2 + lib/runner.php | 273 ++++++++++++++++++++--- tests/phpunit/tests/export/docblocks.inc | 70 ++++++ tests/phpunit/tests/export/docblocks.php | 194 +++++++++++++++- 5 files changed, 503 insertions(+), 39 deletions(-) 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 3c4c0cda..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,17 +355,9 @@ 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 ), ); - $docblock = $method->getDocBlock(); - if ( $docblock ) { - $code_snippets = export_docblock_code_snippets( $docblock->getLongDescription()->getContents() ); - if ( ! empty( $code_snippets ) ) { - $method_data['doc']['code_snippets'] = $code_snippets; - } - } - if ( ! empty( $method->uses ) ) { $method_data['uses'] = export_uses( $method->uses ); @@ -358,22 +373,15 @@ function export_methods( array $methods ) { } /** - * 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. + * Returns Markdown-like backtick fences from a DocBlock's raw long description. * - * @param string $text + * @param string $text Raw DocBlock long description. * * @return array */ -function export_docblock_code_snippets( $text ) { +function get_docblock_code_fences( $text ) { $lines = explode( "\n", preg_replace( "/\r\n?/", "\n", $text ) ); $fences = array(); - $snippets = array(); for ( $i = 0, $line_count = count( $lines ); $i < $line_count; $i++ ) { if ( ! preg_match( '/^([ \t]*)(`{3,})([^`]*)$/', $lines[ $i ], $opening ) ) { @@ -384,6 +392,7 @@ function export_docblock_code_snippets( $text ) { $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. @@ -409,20 +418,56 @@ function export_docblock_code_snippets( $text ) { $fences[] = array( 'language' => strtolower( $language ), - 'info' => strtolower( trim( $opening[3] ) ), + '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; @@ -434,14 +479,20 @@ function export_docblock_code_snippets( $text ) { } $snippet = array( - 'type' => 'php-code-snippet', - 'code' => $fences[ $i ]['code'], - 'expected_output' => '', + '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 ) { - $snippet['blueprint'] = $pending_blueprint; + if ( ! array_key_exists( 'blueprint', $snippet ) ) { + $snippet['blueprint'] = $pending_blueprint; + } $pending_blueprint = null; } @@ -460,6 +511,10 @@ function export_docblock_code_snippets( $text ) { 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; @@ -475,6 +530,79 @@ function export_docblock_code_snippets( $text ) { 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. * @@ -494,8 +622,14 @@ function is_docblock_expected_output_fence( $fence ) { * @return bool */ function is_docblock_blueprint_fence( $fence ) { - return in_array( $fence['language'], array( 'blueprint', 'setup-blueprint' ), true ) - || ( 'json' === $fence['language'] && false !== strpos( ' ' . $fence['info'] . ' ', ' blueprint ' ) ); + 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 ' ) ); } /** @@ -515,6 +649,61 @@ function decode_docblock_blueprint( $blueprint ) { 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. * @@ -574,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 de073f08..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' => " '

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' => '', ) ); } @@ -330,7 +415,7 @@ public function test_code_snippet_fence_parser_edge_cases() { } /** - * Test that PHP snippets export the renderer fields even without metadata. + * Test that PHP snippets can omit optional metadata. */ public function test_code_snippet_without_metadata() { @@ -339,7 +424,6 @@ public function test_code_snippet_without_metadata() { array( 'type' => 'php-code-snippet', 'code' => " '', ), ), \WP_Parser\export_docblock_code_snippets( @@ -412,7 +496,6 @@ public function test_code_snippet_string_blueprint() { array( 'type' => 'php-code-snippet', 'code' => " '', 'blueprint' => 'not-json', ), ), @@ -448,7 +531,6 @@ public function test_code_snippet_metadata_boundaries() { array( 'type' => 'php-code-snippet', 'code' => " '', 'blueprint' => array( 'steps' => array( array( @@ -484,6 +566,108 @@ public function test_code_snippet_metadata_boundaries() { ); } + /** + * 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' => ' 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. */ From e937bd34065321359dfba345bb697ce05dde7e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 18 Jun 2026 14:20:21 +0200 Subject: [PATCH 03/13] Place interactive snippets within long descriptions --- lib/runner.php | 242 +++++++++++--------- tests/phpunit/tests/export/docblocks.inc | 14 +- tests/phpunit/tests/export/docblocks.php | 276 ++++++++++++++++------- 3 files changed, 339 insertions(+), 193 deletions(-) diff --git a/lib/runner.php b/lib/runner.php index db20f8ba..7a2ec204 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -211,8 +211,9 @@ function export_docblock( $element, array $inherited_setup_blueprints = array() } $raw_long_description = $docblock->getLongDescription()->getContents(); + $fences = get_docblock_code_fences( $raw_long_description ); $setup_blueprints = array(); - $code_snippets = export_docblock_code_snippets( $raw_long_description, $setup_blueprints ); + $code_snippets = export_docblock_code_snippets( $raw_long_description, $setup_blueprints, $fences ); $setup_blueprints = array_merge( get_referenced_setup_blueprints( $code_snippets, $inherited_setup_blueprints ), $setup_blueprints @@ -220,7 +221,7 @@ function export_docblock( $element, array $inherited_setup_blueprints = array() $output = array( 'description' => preg_replace( '/[\n\r]+/', ' ', $docblock->getShortDescription() ), - 'long_description' => format_long_description( strip_docblock_code_snippet_fences( $raw_long_description ) ), + 'long_description' => format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) ), 'tags' => array(), ); @@ -380,56 +381,86 @@ function export_methods( array $methods, array $inherited_setup_blueprints = arr * @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 ]; - } + $text = preg_replace( "/\r\n?/", "\n", $text ); + $fences = array(); + $offset = 0; + $line_no = 0; + $length = strlen( $text ); + + // Walk the text one fenced block at a time. A single regex captures each + // block: the `\2` backreference forces the closing fence to repeat the + // opener's backtick run (so longer or shorter fences stay inside the body), + // and the non-greedy `(?:.*\n)*?` stops at the first matching closer. `\G` + // anchors each attempt at the next opener, so an opener with no matching + // closer stops parsing, exactly like the original line-by-line scanner. + $opener_pattern = '/^[ \t]*`{3,}[^`\n]*$/m'; + $block_pattern = '/\G([ \t]*)(`{3,})([^`\n]*)\n((?:.*\n)*?)[ \t]*\2[ \t]*$/m'; + + while ( $offset < $length && preg_match( $opener_pattern, $text, $opening, PREG_OFFSET_CAPTURE, $offset ) ) { + $fence_start = $opening[0][1]; + + if ( ! preg_match( $block_pattern, $text, $block, PREG_OFFSET_CAPTURE, $fence_start ) ) { + break; } - if ( $j === $line_count ) { - break; + $indent = $block[1][0]; + $code = $block[4][0]; + if ( '' !== $indent ) { + // Strip the opening fence's indentation from each content line. + $code = preg_replace( '/^' . preg_quote( $indent, '/' ) . '/m', '', $code ); } + $language = trim( $block[3][0] ); if ( preg_match( '/^\S+/', $language, $language_matches ) ) { $language = $language_matches[0]; } - $fences[] = array( + // Count only the gap since the previous block, never the whole prefix, + // so line numbering stays O(n) across the whole description. + $line_no += substr_count( substr( $text, $offset, $fence_start - $offset ), "\n" ); + $start = $line_no; + + $fence = array( 'language' => strtolower( $language ), - 'info' => trim( $opening[3] ), - 'code' => rtrim( implode( "\n", $code_lines ), "\n" ), - 'start' => $start_line, - 'end' => $i, + 'info' => trim( $block[3][0] ), + 'code' => rtrim( $code, "\n" ), + 'start' => $start, + 'end' => $start + substr_count( $block[0][0], "\n" ), ); + + // Classify each fence once here so the snippet exporter and the + // description stripper share the result instead of recomputing it. + $info_parts = get_docblock_fence_info_parts( $fence ); + $fence['referenced_setup'] = get_docblock_referenced_blueprint_name( $fence ); + $fence['is_interactive_php'] = 'php' === $fence['language'] + && isset( $info_parts[1] ) + && 'interactive' === $info_parts[1] + && ( 2 === count( $info_parts ) || null !== $fence['referenced_setup'] ); + $fence['is_expected_output'] = is_docblock_expected_output_fence( $fence ); + $fence['is_blueprint'] = is_docblock_blueprint_fence( $fence ); + $fence['setup_name'] = get_docblock_setup_blueprint_name( $fence ); + $fence['is_code_snippet'] = $fence['is_interactive_php'] || $fence['is_expected_output'] || $fence['is_blueprint'] || null !== $fence['setup_name']; + + $fences[] = $fence; + + // Continue scanning after this block's closing fence, keeping the line + // counter in sync with the new offset. + $line_no = $fence['end']; + $offset = $block[0][1] + strlen( $block[0][0] ); + } + + // 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; } /** - * Extract runnable PHP snippets from a DocBlock's raw long description. + * 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 @@ -441,10 +472,14 @@ function get_docblock_code_fences( $text ) { * @param string $text Raw DocBlock long description. * @param array $setup_blueprints Optional. Named setup Blueprints keyed by reference name. * + * @throws \InvalidArgumentException When a setup Blueprint is not a valid JSON object. + * * @return array */ -function export_docblock_code_snippets( $text, &$setup_blueprints = null ) { - $fences = get_docblock_code_fences( $text ); +function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fences = null ) { + if ( null === $fences ) { + $fences = get_docblock_code_fences( $text ); + } $snippets = array(); $pending_blueprint = null; @@ -453,9 +488,8 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null ) { $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'] ); + if ( null !== $fence['setup_name'] ) { + $setup_blueprints[ $fence['setup_name'] ] = decode_docblock_blueprint( $fence['code'] ); } } @@ -464,16 +498,16 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null ) { continue; } - if ( null !== get_docblock_setup_blueprint_name( $fences[ $i ] ) ) { + if ( null !== $fences[ $i ]['setup_name'] ) { continue; } - if ( is_docblock_blueprint_fence( $fences[ $i ] ) ) { + if ( $fences[ $i ]['is_blueprint'] ) { $pending_blueprint = decode_docblock_blueprint( $fences[ $i ]['code'] ); continue; } - if ( 'php' !== $fences[ $i ]['language'] ) { + if ( ! $fences[ $i ]['is_interactive_php'] ) { $pending_blueprint = null; continue; } @@ -482,11 +516,9 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null ) { '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 !== $fences[ $i ]['referenced_setup'] ) { + $snippet['blueprint'] = $fences[ $i ]['referenced_setup']; } if ( null !== $pending_blueprint ) { @@ -497,25 +529,22 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null ) { } for ( $j = $i + 1; $j < $fence_count; $j++ ) { - if ( 'php' === $fences[ $j ]['language'] ) { + if ( $fences[ $j ]['is_interactive_php'] ) { 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; - } - + 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 !== get_docblock_setup_blueprint_name( $fences[ $j ] ) ) { + if ( null !== $fences[ $j ]['setup_name'] ) { break; } - if ( is_docblock_blueprint_fence( $fences[ $j ] ) && ! array_key_exists( 'blueprint', $snippet ) ) { + if ( $fences[ $j ]['is_blueprint'] && ! array_key_exists( 'blueprint', $snippet ) ) { $snippet['blueprint'] = decode_docblock_blueprint( $fences[ $j ]['code'] ); $consumed_fences[ $j ] = true; continue; @@ -541,23 +570,37 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null ) { * * @return string */ -function strip_docblock_code_snippet_fences( $text ) { - $text = preg_replace( "/\r\n?/", "\n", $text ); - $lines = explode( "\n", $text ); - $remove_lines = array(); +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 ( get_docblock_code_fences( $text ) as $fence ) { - if ( ! is_docblock_code_snippet_fence( $fence ) ) { + foreach ( $fences as $fence ) { + if ( ! $fence['is_code_snippet'] ) { continue; } + // Interactive PHP fences become `code_snippets` entries; replace each one with an + // inline placeholder, keyed by the fence's shared snippet index, so the + // theme renders the runnable snippet in place between the surrounding + // prose. Snippet-metadata fences (expected-output, Blueprints) are removed. for ( $i = $fence['start']; $i <= $fence['end']; $i++ ) { - $remove_lines[ $i ] = true; + if ( $fence['is_interactive_php'] && $i === $fence['start'] ) { + $replace_lines[ $i ] = docblock_code_snippet_placeholder( $fence['snippet_index'] ); + } else { + $remove_lines[ $i ] = true; + } } } foreach ( $lines as $line_number => $line ) { - if ( isset( $remove_lines[ $line_number ] ) ) { + if ( isset( $replace_lines[ $line_number ] ) ) { + $lines[ $line_number ] = $replace_lines[ $line_number ]; + } elseif ( isset( $remove_lines[ $line_number ] ) ) { unset( $lines[ $line_number ] ); } } @@ -565,6 +608,20 @@ function strip_docblock_code_snippet_fences( $text ) { return trim( implode( "\n", $lines ) ); } +/** + * Inline placeholder left in `long_description` for the Nth PHP code snippet. + * + * A plain HTML comment so it survives Markdown rendering, `the_content`, and the + * block parser untouched; the theme replaces it with the rendered runnable + * snippet, keeping snippets positioned between the surrounding prose. + * + * @param int $index Zero-based index into `code_snippets`. + * @return string + */ +function docblock_code_snippet_placeholder( $index ) { + return ''; +} + /** * Returns inherited setup Blueprints referenced by the snippets. * @@ -589,20 +646,6 @@ function get_referenced_setup_blueprints( $snippets, $setup_blueprints ) { 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. * @@ -611,7 +654,7 @@ function is_docblock_code_snippet_fence( $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 ); + return 'expected-output' === $fence['language'] && 1 === count( get_docblock_fence_info_parts( $fence ) ); } /** @@ -622,14 +665,7 @@ function is_docblock_expected_output_fence( $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 ' ) ); + return 'setup-blueprint' === $fence['language'] && 1 === count( get_docblock_fence_info_parts( $fence ) ); } /** @@ -637,16 +673,22 @@ function is_docblock_blueprint_fence( $fence ) { * * @param string $blueprint * - * @return array|string + * @throws \InvalidArgumentException When the Blueprint is not a valid JSON object. + * + * @return array */ function decode_docblock_blueprint( $blueprint ) { $decoded = json_decode( $blueprint, true ); - if ( is_array( $decoded ) ) { - return $decoded; + if ( JSON_ERROR_NONE !== json_last_error() ) { + throw new \InvalidArgumentException( 'Blueprint must contain valid JSON: ' . json_last_error_msg() ); + } + + if ( '{' !== substr( ltrim( $blueprint ), 0, 1 ) || ! is_array( $decoded ) ) { + throw new \InvalidArgumentException( 'Blueprint must be a JSON object.' ); } - return $blueprint; + return $decoded; } /** @@ -659,14 +701,10 @@ function decode_docblock_blueprint( $blueprint ) { 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] ) ) { + if ( 'setup-blueprint' === $fence['language'] && 2 === count( $info_parts ) ) { 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; } @@ -678,10 +716,10 @@ function get_docblock_setup_blueprint_name( $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]; - } + $info_parts = get_docblock_fence_info_parts( $fence ); + + if ( 'php' === $fence['language'] && 3 === count( $info_parts ) && preg_match( '/^setup-blueprint=(.+)$/', $info_parts[2], $matches ) ) { + return $matches[1]; } return null; diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 4fedc1c5..d85e20b6 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -7,7 +7,7 @@ * fact, this one does. It spans more than two full lines, continuing on to the * third line. * - * ```setupblueprint file-greeting + * ```setup-blueprint file-greeting * { * "steps": [ * { @@ -79,7 +79,7 @@ class Test_Class { * * Use this example: * - * ```blueprint + * ```setup-blueprint * { * "steps": [ * { @@ -91,7 +91,7 @@ class Test_Class { * } * ``` * - * ```php + * ```php interactive * '

Use this example:

', + 'long_description' => '

Use this example:

', ) ); } @@ -240,7 +240,7 @@ public function test_method_file_setup_blueprint() { 'blueprint' => 'file-greeting', ), ), - 'long_description' => '', + 'long_description' => '', ) ); } @@ -259,7 +259,7 @@ public function test_code_snippet_fence_parser_edge_cases() { 'Two backticks are too short.', '``', '', - '````php title="outer.php"', + '````php interactive', 'assertEquals( array(), \WP_Parser\export_docblock_code_snippets( $description ) ); + $this->assertEquals( $description, \WP_Parser\strip_docblock_code_snippet_fences( $description ) ); + } + + /** + * 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_fence_info_strings() { + 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 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' => " 'case fixture', - 'blueprint' => array( - 'steps' => array( - array( - 'step' => 'writeFile', - 'path' => '/tmp/case.php', - 'data' => 'assertEquals( + $description = implode( + "\n", array( + '```blueprint', + '{"steps":[]}', + '```', + '```setupblueprint shared', + '{"steps":[]}', + '```', + '```json setup-blueprint shared', + '{"steps":[]}', + '```', + '```php interactive blueprint=shared', + 'assertEquals( array(), \WP_Parser\export_docblock_code_snippets( $description ) ); + $this->assertEquals( $description, \WP_Parser\strip_docblock_code_snippet_fences( $description ) ); + } + + /** + * Test that invalid setup Blueprint JSON stops snippet export. + * + * @dataProvider invalid_setup_blueprints + */ + public function test_invalid_setup_blueprint_json_fails( $fence_info, $blueprint ) { + + $this->expectException( \InvalidArgumentException::class ); + + \WP_Parser\export_docblock_code_snippets( + implode( + "\n", array( - 'type' => 'php-code-snippet', - 'code' => " 'not-json', - ), - ), - \WP_Parser\export_docblock_code_snippets( - implode( - "\n", - array( - '```blueprint', - 'not-json', - '```', - '```php', - ' 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 metadata after expected output belongs to the next snippet. */ @@ -546,17 +679,17 @@ public function test_code_snippet_metadata_boundaries() { implode( "\n", array( - '```php', + '```php interactive', ' array( - 'steps' => array( - array( - 'step' => 'writeFile', - 'path' => '/tmp/json-shared.php', - 'data' => ' " 'no leaked inline blueprint', ), - array( - 'type' => 'php-code-snippet', - 'code' => " 'second', - 'blueprint' => 'json-shared', - ), array( 'type' => 'php-code-snippet', 'code' => " Date: Sat, 18 Jul 2026 22:17:40 +0200 Subject: [PATCH 04/13] Validate snippet metadata and inherited setups --- lib/runner.php | 276 ++++++++++++----- tests/phpunit/tests/export/docblocks.inc | 20 ++ tests/phpunit/tests/export/docblocks.php | 282 +++++++++++++++++- .../tests/export/invalid-blueprint.inc | 11 + .../tests/export/undefined-blueprint.inc | 11 + tests/phpunit/tests/import/file.php | 36 +++ 6 files changed, 557 insertions(+), 79 deletions(-) create mode 100644 tests/phpunit/tests/export/invalid-blueprint.inc create mode 100644 tests/phpunit/tests/export/undefined-blueprint.inc diff --git a/lib/runner.php b/lib/runner.php index 7a2ec204..b90a44c7 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; @@ -55,7 +56,7 @@ function parse_files( $files, $root ) { $file->process(); - $file_doc = export_docblock( $file ); + $file_doc = export_docblock( $file, array(), $path ); $file_setup_blueprints = $file_doc['setup_blueprints'] ?? array(); // TODO proper exporter @@ -86,7 +87,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 ) { @@ -97,7 +98,7 @@ function parse_files( $files, $root ) { 'line' => $function->getLineNumber(), 'end_line' => $function->getNode()->getAttribute( 'endLine' ), 'arguments' => export_arguments( $function->getArguments() ), - 'doc' => export_docblock( $function, $file_setup_blueprints ), + 'doc' => export_docblock( $function, $file_setup_blueprints, $path ), 'hooks' => array(), ); @@ -105,7 +106,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 ); } } @@ -113,7 +114,7 @@ function parse_files( $files, $root ) { } foreach ( $file->getClasses() as $class ) { - $class_doc = export_docblock( $class, $file_setup_blueprints ); + $class_doc = export_docblock( $class, $file_setup_blueprints, $path ); $class_setup_blueprints = array_merge( $file_setup_blueprints, $class_doc['setup_blueprints'] ?? array() ); $class_data = array( @@ -125,8 +126,8 @@ function parse_files( $files, $root ) { 'abstract' => $class->isAbstract(), 'extends' => $class->getParentClass(), 'implements' => $class->getInterfaces(), - 'properties' => export_properties( $class->getProperties() ), - 'methods' => export_methods( $class->getMethods(), $class_setup_blueprints ), + 'properties' => export_properties( $class->getProperties(), $class_setup_blueprints, $path ), + 'methods' => export_methods( $class->getMethods(), $class_setup_blueprints, $path ), 'doc' => $class_doc, ); @@ -197,10 +198,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. + * @param string $source_file Optional. Source path used in invalid snippet metadata errors. * * @return array */ -function export_docblock( $element, array $inherited_setup_blueprints = array() ) { +function export_docblock( $element, array $inherited_setup_blueprints = array(), $source_file = '' ) { $docblock = $element->getDocBlock(); if ( ! $docblock ) { return array( @@ -210,14 +212,23 @@ function export_docblock( $element, array $inherited_setup_blueprints = array() ); } - $raw_long_description = $docblock->getLongDescription()->getContents(); - $fences = get_docblock_code_fences( $raw_long_description ); - $setup_blueprints = array(); - $code_snippets = export_docblock_code_snippets( $raw_long_description, $setup_blueprints, $fences ); - $setup_blueprints = array_merge( - get_referenced_setup_blueprints( $code_snippets, $inherited_setup_blueprints ), - $setup_blueprints - ); + try { + $raw_long_description = $docblock->getLongDescription()->getContents(); + $fences = get_docblock_code_fences( $raw_long_description ); + $setup_blueprints = array(); + $code_snippets = export_docblock_code_snippets( $raw_long_description, $setup_blueprints, $fences ); + $setup_blueprints = array_merge( + get_referenced_setup_blueprints( $code_snippets, $inherited_setup_blueprints ), + $setup_blueprints + ); + validate_docblock_setup_blueprint_references( $code_snippets, $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() ), @@ -269,12 +280,48 @@ function export_docblock( $element, array $inherited_setup_blueprints = array() return $output; } +/** + * 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 ) { @@ -284,7 +331,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 ), ); } @@ -312,10 +359,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 ) { @@ -327,7 +376,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 ), ); } @@ -337,10 +386,11 @@ 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, array $inherited_setup_blueprints = array() ) { +function export_methods( array $methods, array $inherited_setup_blueprints = array(), $source_file = '' ) { $output = array(); foreach ( $methods as $method ) { @@ -356,14 +406,14 @@ function export_methods( array $methods, array $inherited_setup_blueprints = arr 'static' => $method->isStatic(), 'visibility' => $method->getVisibility(), 'arguments' => export_arguments( $method->getArguments() ), - 'doc' => export_docblock( $method, $inherited_setup_blueprints ), + '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 ); } } @@ -381,51 +431,54 @@ function export_methods( array $methods, array $inherited_setup_blueprints = arr * @return array */ function get_docblock_code_fences( $text ) { - $text = preg_replace( "/\r\n?/", "\n", $text ); - $fences = array(); - $offset = 0; - $line_no = 0; - $length = strlen( $text ); - - // Walk the text one fenced block at a time. A single regex captures each - // block: the `\2` backreference forces the closing fence to repeat the - // opener's backtick run (so longer or shorter fences stay inside the body), - // and the non-greedy `(?:.*\n)*?` stops at the first matching closer. `\G` - // anchors each attempt at the next opener, so an opener with no matching - // closer stops parsing, exactly like the original line-by-line scanner. - $opener_pattern = '/^[ \t]*`{3,}[^`\n]*$/m'; - $block_pattern = '/\G([ \t]*)(`{3,})([^`\n]*)\n((?:.*\n)*?)[ \t]*\2[ \t]*$/m'; - - while ( $offset < $length && preg_match( $opener_pattern, $text, $opening, PREG_OFFSET_CAPTURE, $offset ) ) { - $fence_start = $opening[0][1]; - - if ( ! preg_match( $block_pattern, $text, $block, PREG_OFFSET_CAPTURE, $fence_start ) ) { + $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; } - $indent = $block[1][0]; - $code = $block[4][0]; + $code_lines = array_slice( $lines, $line_no + 1, $end - $line_no - 1 ); if ( '' !== $indent ) { // Strip the opening fence's indentation from each content line. - $code = preg_replace( '/^' . preg_quote( $indent, '/' ) . '/m', '', $code ); + foreach ( $code_lines as $key => $code_line ) { + if ( 0 === strpos( $code_line, $indent ) ) { + $code_lines[ $key ] = substr( $code_line, strlen( $indent ) ); + } + } } - $language = trim( $block[3][0] ); + $language = trim( $opening[3] ); if ( preg_match( '/^\S+/', $language, $language_matches ) ) { $language = $language_matches[0]; } - // Count only the gap since the previous block, never the whole prefix, - // so line numbering stays O(n) across the whole description. - $line_no += substr_count( substr( $text, $offset, $fence_start - $offset ), "\n" ); - $start = $line_no; - $fence = array( - 'language' => strtolower( $language ), - 'info' => trim( $block[3][0] ), - 'code' => rtrim( $code, "\n" ), - 'start' => $start, - 'end' => $start + substr_count( $block[0][0], "\n" ), + 'language' => $language, + 'info' => trim( $opening[3] ), + 'code' => rtrim( implode( "\n", $code_lines ), "\n" ), + 'start' => $line_no, + 'end' => $end, ); // Classify each fence once here so the snippet exporter and the @@ -443,10 +496,7 @@ function get_docblock_code_fences( $text ) { $fences[] = $fence; - // Continue scanning after this block's closing fence, keeping the line - // counter in sync with the new offset. - $line_no = $fence['end']; - $offset = $block[0][1] + strlen( $block[0][0] ); + $line_no = $end; } // Number the interactive PHP fences so the exporter and the stripper agree on each @@ -467,7 +517,8 @@ function get_docblock_code_fences( $text ) { * 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. + * 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. * * @param string $text Raw DocBlock long description. * @param array $setup_blueprints Optional. Named setup Blueprints keyed by reference name. @@ -480,16 +531,18 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence if ( null === $fences ) { $fences = get_docblock_code_fences( $text ); } + $lines = explode( "\n", preg_replace( "/\r\n?/", "\n", $text ) ); $snippets = array(); - $pending_blueprint = null; - $consumed_fences = array(); - $fence_count = count( $fences ); - $setup_blueprints = array(); + $pending_blueprint = null; + $pending_blueprint_fence = null; + $consumed_fences = array(); + $fence_count = count( $fences ); + $setup_blueprints = array(); foreach ( $fences as $fence ) { if ( null !== $fence['setup_name'] ) { - $setup_blueprints[ $fence['setup_name'] ] = decode_docblock_blueprint( $fence['code'] ); + $setup_blueprints[ $fence['setup_name'] ] = decode_docblock_blueprint( $fence['code'], $fence ); } } @@ -503,12 +556,14 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence } if ( $fences[ $i ]['is_blueprint'] ) { - $pending_blueprint = decode_docblock_blueprint( $fences[ $i ]['code'] ); + $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 = null; + $pending_blueprint_fence = null; continue; } @@ -521,14 +576,23 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence $snippet['blueprint'] = $fences[ $i ]['referenced_setup']; } - if ( null !== $pending_blueprint ) { + if ( + null !== $pending_blueprint && + docblock_fences_have_only_whitespace_between( $fences[ $pending_blueprint_fence ], $fences[ $i ], $lines ) + ) { if ( ! array_key_exists( 'blueprint', $snippet ) ) { $snippet['blueprint'] = $pending_blueprint; } - $pending_blueprint = null; } + $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; } @@ -545,8 +609,9 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence } if ( $fences[ $j ]['is_blueprint'] && ! array_key_exists( 'blueprint', $snippet ) ) { - $snippet['blueprint'] = decode_docblock_blueprint( $fences[ $j ]['code'] ); + $snippet['blueprint'] = decode_docblock_blueprint( $fences[ $j ]['code'], $fences[ $j ] ); $consumed_fences[ $j ] = true; + $previous_fence = $j; continue; } @@ -559,6 +624,28 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $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. * @@ -646,6 +733,25 @@ function get_referenced_setup_blueprints( $snippets, $setup_blueprints ) { return $referenced_setup_blueprints; } +/** + * Rejects snippet references that do not resolve to an available setup Blueprint. + * + * @param array $snippets Exported code snippets. + * @param array $setup_blueprints Setup Blueprints available to the DocBlock. + * + * @throws \InvalidArgumentException When a snippet references an undefined setup Blueprint. + */ +function validate_docblock_setup_blueprint_references( $snippets, $setup_blueprints ) { + foreach ( $snippets as $snippet ) { + if ( + is_string( $snippet['blueprint'] ?? null ) && + ! array_key_exists( $snippet['blueprint'], $setup_blueprints ) + ) { + throw new \InvalidArgumentException( 'Setup Blueprint "' . $snippet['blueprint'] . '" is not defined.' ); + } + } +} + /** * Checks whether a parsed DocBlock fence contains snippet expected output. * @@ -671,21 +777,30 @@ function is_docblock_blueprint_fence( $fence ) { /** * Decodes a Blueprint fence into the structure exported to JSON. * - * @param string $blueprint + * @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 */ -function decode_docblock_blueprint( $blueprint ) { +function decode_docblock_blueprint( $blueprint, $fence = null ) { $decoded = json_decode( $blueprint, true ); + $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() ) { - throw new \InvalidArgumentException( 'Blueprint must contain valid JSON: ' . json_last_error_msg() ); + throw new \InvalidArgumentException( $label . ' must contain valid JSON: ' . json_last_error_msg() ); } if ( '{' !== substr( ltrim( $blueprint ), 0, 1 ) || ! is_array( $decoded ) ) { - throw new \InvalidArgumentException( 'Blueprint must be a JSON object.' ); + throw new \InvalidArgumentException( $label . ' must be a JSON object.' ); } return $decoded; @@ -809,6 +924,17 @@ function export_uses( array $uses ) { * 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 ); diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index d85e20b6..9ab2b8bb 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -54,6 +54,16 @@ class Test_Class { /** * This is a docblock for a class property. * + * ```php interactive setup-blueprint=file-greeting + * 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,29 @@ 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' => array( + 'file-greeting' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/wordpress/wp-content/mu-plugins/file-greeting.php', + 'data' => "assertHookHasDocs( @@ -414,6 +447,32 @@ public function test_code_snippet_fence_parser_edge_cases() { ); } + /** + * 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( $description, \WP_Parser\strip_docblock_code_snippet_fences( $description ) ); } + /** + * Test that capitalization variants are treated as ordinary documentation. + */ + public function test_code_snippet_syntax_is_case_sensitive() { + + $description = implode( + "\n", + array( + '```PHP interactive', + 'assertEquals( array(), \WP_Parser\export_docblock_code_snippets( $description ) ); + $this->assertEquals( $description, \WP_Parser\strip_docblock_code_snippet_fences( $description ) ); + } + /** * Test that invalid setup Blueprint JSON stops snippet export. * @@ -649,6 +750,107 @@ public function invalid_setup_blueprints() { ); } + /** + * 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 named setup Blueprint references must resolve in the DocBlock scope. + */ + public function test_undefined_setup_blueprint_reference_fails() { + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Setup Blueprint "missing" is not defined.' ); + + \WP_Parser\validate_docblock_setup_blueprint_references( + array( + array( + 'type' => 'php-code-snippet', + 'code' => ' 'missing', + ), + ), + array() + ); + } + + /** + * Test that inline and inherited setup Blueprints satisfy validation. + */ + public function test_setup_blueprint_reference_validation_accepts_available_blueprints() { + + \WP_Parser\validate_docblock_setup_blueprint_references( + array( + array( + 'type' => 'php-code-snippet', + 'code' => ' array( 'steps' => array() ), + ), + array( + 'type' => 'php-code-snippet', + 'code' => ' 'inherited', + ), + ), + array( + 'inherited' => array( 'steps' => array() ), + ) + ); + + $this->assertTrue( true ); + } + + /** + * 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" is not defined.', + ), + ); + } + /** * Test that metadata after expected output belongs to the next snippet. */ @@ -699,6 +901,56 @@ public function test_code_snippet_metadata_boundaries() { ); } + /** + * Test that snippet metadata does not cross intervening prose. + */ + public function test_code_snippet_metadata_does_not_cross_prose() { + + $blueprint_before_prose = implode( + "\n", + array( + '```setup-blueprint', + '{"steps":[]}', + '```', + 'This setup belongs to another example.', + '```php interactive', + 'assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => 'assertEquals( + array( + array( + 'type' => 'php-code-snippet', + 'code' => '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', + ), + ), + 'setup_blueprints' => array( + 'file-greeting' => array( + 'steps' => array( + array( + 'step' => 'writeFile', + 'path' => '/wordpress/wp-content/mu-plugins/file-greeting.php', + 'data' => "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 ) ); + } } From 0c90baed331818ae04e0536c5642b1455401a1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 18 Jul 2026 23:36:42 +0200 Subject: [PATCH 05/13] Preserve Blueprint JSON shapes during import --- lib/class-command.php | 9 +- lib/class-importer.php | 4 +- lib/runner.php | 200 ++++++++++++++++-- tests/phpunit/tests/export/docblocks.php | 245 +++++++++++++++++------ tests/phpunit/tests/import/command.php | 87 ++++++++ 5 files changed, 466 insertions(+), 79 deletions(-) create mode 100644 tests/phpunit/tests/import/command.php diff --git a/lib/class-command.php b/lib/class-command.php index 4fdad914..7ea473ba 100644 --- a/lib/class-command.php +++ b/lib/class-command.php @@ -57,11 +57,16 @@ 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 ); // Import data $this->_do_import( $phpdoc, isset( $assoc_args['quick'] ), isset( $assoc_args['import-internal'] ) ); diff --git a/lib/class-importer.php b/lib/class-importer.php index 44ad563e..f093cbfb 100644 --- a/lib/class-importer.php +++ b/lib/class-importer.php @@ -760,8 +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_code_snippets', isset( $data['doc']['code_snippets'] ) ? $data['doc']['code_snippets'] : array() ); + $anything_updated[] = update_post_meta( $post_id, '_wp-parser_setup_blueprints', isset( $data['doc']['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 b90a44c7..af8b4d45 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -57,7 +57,7 @@ function parse_files( $files, $root ) { $file->process(); $file_doc = export_docblock( $file, array(), $path ); - $file_setup_blueprints = $file_doc['setup_blueprints'] ?? array(); + $file_setup_blueprints = isset( $file_doc['setup_blueprints'] ) ? $file_doc['setup_blueprints'] : array(); // TODO proper exporter $out = array( @@ -115,7 +115,7 @@ 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, $class_doc['setup_blueprints'] ?? array() ); + $class_setup_blueprints = array_merge( $file_setup_blueprints, isset( $class_doc['setup_blueprints'] ) ? $class_doc['setup_blueprints'] : array() ); $class_data = array( 'name' => $class->getShortName(), @@ -215,13 +215,14 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), try { $raw_long_description = $docblock->getLongDescription()->getContents(); $fences = get_docblock_code_fences( $raw_long_description ); + validate_docblock_setup_blueprint_scope( $fences, $inherited_setup_blueprints ); $setup_blueprints = array(); $code_snippets = export_docblock_code_snippets( $raw_long_description, $setup_blueprints, $fences ); $setup_blueprints = array_merge( get_referenced_setup_blueprints( $code_snippets, $inherited_setup_blueprints ), $setup_blueprints ); - validate_docblock_setup_blueprint_references( $code_snippets, $setup_blueprints ); + validate_docblock_setup_blueprint_references( $code_snippets, $setup_blueprints, $fences ); } catch ( \InvalidArgumentException $exception ) { throw new \InvalidArgumentException( describe_docblock_source( $element, $docblock, $source_file ) . ': ' . $exception->getMessage(), @@ -519,6 +520,7 @@ function get_docblock_code_fences( $text ) { * 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. @@ -539,10 +541,20 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence $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; } } @@ -556,6 +568,17 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence } 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; @@ -580,9 +603,16 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence null !== $pending_blueprint && docblock_fences_have_only_whitespace_between( $fences[ $pending_blueprint_fence ], $fences[ $i ], $lines ) ) { - if ( ! array_key_exists( 'blueprint', $snippet ) ) { - $snippet['blueprint'] = $pending_blueprint; + // 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; @@ -608,7 +638,14 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence break; } - if ( $fences[ $j ]['is_blueprint'] && ! array_key_exists( 'blueprint', $snippet ) ) { + 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; @@ -621,6 +658,28 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence $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; } @@ -721,7 +780,7 @@ function get_referenced_setup_blueprints( $snippets, $setup_blueprints ) { $referenced_setup_blueprints = array(); foreach ( $snippets as $snippet ) { - if ( ! is_string( $snippet['blueprint'] ?? null ) ) { + if ( ! isset( $snippet['blueprint'] ) || ! is_string( $snippet['blueprint'] ) ) { continue; } @@ -738,16 +797,50 @@ function get_referenced_setup_blueprints( $snippets, $setup_blueprints ) { * * @param array $snippets Exported code snippets. * @param array $setup_blueprints Setup Blueprints available to the DocBlock. + * @param array $fences Optional parsed fences used to identify unresolved references. * * @throws \InvalidArgumentException When a snippet references an undefined setup Blueprint. */ -function validate_docblock_setup_blueprint_references( $snippets, $setup_blueprints ) { - foreach ( $snippets as $snippet ) { +function validate_docblock_setup_blueprint_references( $snippets, $setup_blueprints, $fences = array() ) { + $snippet_lines = array(); + foreach ( $fences as $fence ) { + if ( $fence['is_interactive_php'] ) { + $snippet_lines[ $fence['snippet_index'] ] = $fence['start'] + 1; + } + } + + foreach ( $snippets as $index => $snippet ) { if ( - is_string( $snippet['blueprint'] ?? null ) && + isset( $snippet['blueprint'] ) && + is_string( $snippet['blueprint'] ) && ! array_key_exists( $snippet['blueprint'], $setup_blueprints ) ) { - throw new \InvalidArgumentException( 'Setup Blueprint "' . $snippet['blueprint'] . '" is not defined.' ); + $location = isset( $snippet_lines[ $index ] ) + ? ' referenced on line ' . $snippet_lines[ $index ] . ' of the long description' + : ''; + throw new \InvalidArgumentException( 'Setup Blueprint "' . $snippet['blueprint'] . '"' . $location . ' is not defined.' ); + } + } +} + +/** + * Rejects local setup Blueprint definitions that shadow an enclosing DocBlock. + * + * @param array $fences Parsed DocBlock fences. + * @param array $inherited_setup_blueprints Setup Blueprints inherited from enclosing DocBlocks. + * + * @throws \InvalidArgumentException When a local definition reuses an inherited name. + */ +function validate_docblock_setup_blueprint_scope( $fences, $inherited_setup_blueprints ) { + 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.' + ); } } } @@ -782,10 +875,10 @@ function is_docblock_blueprint_fence( $fence ) { * * @throws \InvalidArgumentException When the Blueprint is not a valid JSON object. * - * @return array + * @return array|\stdClass */ function decode_docblock_blueprint( $blueprint, $fence = null ) { - $decoded = json_decode( $blueprint, true ); + $decoded = json_decode( $blueprint ); $label = 'Setup Blueprint'; if ( is_array( $fence ) ) { @@ -796,16 +889,59 @@ function decode_docblock_blueprint( $blueprint, $fence = null ) { } if ( JSON_ERROR_NONE !== json_last_error() ) { - throw new \InvalidArgumentException( $label . ' must contain valid JSON: ' . json_last_error_msg() ); + $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 ( '{' !== substr( ltrim( $blueprint ), 0, 1 ) || ! is_array( $decoded ) ) { + 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; +} + /** * Returns the reference name for a reusable setup Blueprint fence. * @@ -817,6 +953,7 @@ function get_docblock_setup_blueprint_name( $fence ) { $info_parts = get_docblock_fence_info_parts( $fence ); if ( 'setup-blueprint' === $fence['language'] && 2 === count( $info_parts ) ) { + validate_docblock_setup_blueprint_name( $info_parts[1], $fence ); return $info_parts[1]; } @@ -833,13 +970,42 @@ function get_docblock_setup_blueprint_name( $fence ) { function get_docblock_referenced_blueprint_name( $fence ) { $info_parts = get_docblock_fence_info_parts( $fence ); - if ( 'php' === $fence['language'] && 3 === count( $info_parts ) && preg_match( '/^setup-blueprint=(.+)$/', $info_parts[2], $matches ) ) { - return $matches[1]; + if ( + 'php' === $fence['language'] && + 3 === count( $info_parts ) && + 'interactive' === $info_parts[1] && + 0 === strpos( $info_parts[2], 'setup-blueprint=' ) + ) { + $name = substr( $info_parts[2], strlen( 'setup-blueprint=' ) ); + validate_docblock_setup_blueprint_name( $name, $fence ); + return $name; } return null; } +/** + * 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 array $fence Parsed fence used to identify invalid input. + * + * @throws \InvalidArgumentException When the name is not lowercase kebab-case. + */ +function validate_docblock_setup_blueprint_name( $name, $fence ) { + if ( preg_match( '/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/D', $name ) ) { + return; + } + + throw new \InvalidArgumentException( + 'Setup Blueprint name "' . $name . '" on line ' . ( $fence['start'] + 1 ) . + ' of the long description must be lowercase kebab-case starting with a letter.' + ); +} + /** * Splits the full fence info string into whitespace-delimited parts. * diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index b8df38b8..6459be98 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -303,10 +303,6 @@ public function test_code_snippet_fence_parser_edge_cases() { 'outer', '````', '', - '```expected-output', - 'This expected output appears before PHP and is ignored.', - '```', - '', '```php interactive', '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() { + + $fences = \WP_Parser\get_docblock_code_fences( + "Introductory prose.\n```setup-blueprint shared\n{}\n```" + ); + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Setup Blueprint "shared" on line 2 of the long description is already defined in an enclosing DocBlock.' ); + + \WP_Parser\validate_docblock_setup_blueprint_scope( + $fences, + array( 'shared' => array( 'steps' => array() ) ) + ); + } + /** * Test that invalid Blueprint failures identify the definition location. */ @@ -817,6 +946,33 @@ public function test_setup_blueprint_reference_validation_accepts_available_blue $this->assertTrue( true ); } + /** + * 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. * @@ -846,7 +1002,7 @@ public function invalid_blueprint_source_files() { 'undefined reference' => array( 'undefined-blueprint.inc', 'undefined_blueprint_example', - 'Setup Blueprint "missing" is not defined.', + 'Setup Blueprint "missing" referenced on line 1 of the long description is not defined.', ), ); } @@ -902,52 +1058,28 @@ public function test_code_snippet_metadata_boundaries() { } /** - * Test that snippet metadata does not cross intervening prose. + * Test that recognized snippet metadata must belong to an interactive fence. + * + * @dataProvider unattached_snippet_metadata */ - public function test_code_snippet_metadata_does_not_cross_prose() { + public function test_unattached_snippet_metadata_fails( $description ) { - $blueprint_before_prose = implode( - "\n", - array( - '```setup-blueprint', - '{"steps":[]}', - '```', - 'This setup belongs to another example.', - '```php interactive', - 'expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'is not attached to an interactive PHP fence' ); - $this->assertEquals( - array( - array( - 'type' => 'php-code-snippet', - 'code' => 'assertEquals( - array( - array( - 'type' => 'php-code-snippet', - 'code' => ' 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```" ), ); } @@ -964,9 +1096,6 @@ public function test_code_snippet_named_setup_blueprints() { '```setup-blueprint shared', '{"steps":[{"step":"writeFile","path":"/tmp/shared.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( + 'object' => array( '{}' ), + '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() ); + } + + 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 ); + } + + 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; + } +} From 7c7b8095d9ca0a568ec36abab5c013901241638b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 20 Jul 2026 13:06:30 +0200 Subject: [PATCH 06/13] Recover fenced DocBlocks from raw source --- lib/class-command.php | 30 + lib/class-file-reflector.php | 88 +++ lib/class-importer.php | 10 +- lib/runner.php | 566 +++++++++++------- tests/phpunit/tests/export/docblocks.inc | 2 + tests/phpunit/tests/export/docblocks.php | 273 +++++++-- .../tests/export/fence-first-docblocks.inc | 63 ++ .../tests/export/shadowed-blueprint.inc | 19 + tests/phpunit/tests/import/command.php | 66 +- tests/phpunit/tests/import/file.php | 42 ++ 10 files changed, 883 insertions(+), 276 deletions(-) create mode 100644 tests/phpunit/tests/export/fence-first-docblocks.inc create mode 100644 tests/phpunit/tests/export/shadowed-blueprint.inc diff --git a/lib/class-command.php b/lib/class-command.php index 7ea473ba..0cd8b07e 100644 --- a/lib/class-command.php +++ b/lib/class-command.php @@ -68,6 +68,36 @@ public function import( $args, $assoc_args ) { } 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'] ) || + ! is_string( $parsed_file['path'] ) || + '' === $parsed_file['path'] || + ! isset( $parsed_file['file'] ) || + ! is_array( $parsed_file['file'] ) || + ! isset( $parsed_file['file']['description'] ) || + ! is_string( $parsed_file['file']['description'] ) || + ! isset( $parsed_file['file']['long_description'] ) || + ! is_string( $parsed_file['file']['long_description'] ) || + ! isset( $parsed_file['file']['tags'] ) || + ! 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..8b4da3bb 100644 --- a/lib/class-file-reflector.php +++ b/lib/class-file-reflector.php @@ -44,6 +44,76 @@ class File_Reflector extends FileReflector { */ protected $last_doc = null; + /** + * Whether the file DocBlock was parsed with its complete fence bodies blanked. + * + * @var bool + */ + protected $docblock_was_sanitized = false; + + /** + * Reports whether false in-fence tags were absent from the parsed DocBlock. + * + * @return bool + */ + public function wasDocBlockSanitized() { + return $this->docblock_was_sanitized; + } + + /** + * Let phpDocumentor identify a file DocBlock without parsing fenced PHP as tags. + * + * FileReflector parses and removes the file comment before visiting individual + * nodes. Temporarily blanking complete fence bodies preserves that lifecycle; + * export_docblock() later recovers the untouched source from the file contents. + * + * @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 +214,24 @@ public function leaveNode( \PHPParser_Node $node ) { parent::leaveNode( $node ); switch ( $node->getType() ) { + case 'Stmt_Property': + /* + * phpDocumentor reads a property's DocBlock from the enclosing Property + * node, but PropertyReflector::getNode() exposes one PropertyProperty + * child. Copy the raw comment after traversal so export_docblock() can + * recover fenced source through the same node API as other reflectors, + * without making the child look documented while hooks are discovered. + */ + $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 f093cbfb..c1c264e1 100644 --- a/lib/class-importer.php +++ b/lib/class-importer.php @@ -760,8 +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'] ); - $anything_updated[] = update_post_meta( $post_id, '_wp-parser_code_snippets', isset( $data['doc']['code_snippets'] ) ? $data['doc']['code_snippets'] : array() ); - $anything_updated[] = update_post_meta( $post_id, '_wp-parser_setup_blueprints', isset( $data['doc']['setup_blueprints'] ) ? $data['doc']['setup_blueprints'] : array() ); + + // 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 af8b4d45..ced2d196 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -203,7 +203,37 @@ function ( $matches ) use ( $replacement_string ) { * @return array */ 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 parses fenced lines beginning with `@` as tags. Once one + * such line starts its tag block, a later valid PHP expression such as + * `@! file_exists()` is an invalid tag and makes getDocBlock() return null. + * Retry with only complete fence bodies blanked, then restore the AST + * comment. The parsed object supplies tags and namespace context; the raw + * source below still supplies the complete description and snippet code. + */ + $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' => '', @@ -211,18 +241,183 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), 'tags' => array(), ); } + $fenced_docblock_tag_names = array(); try { + $short_description = $docblock->getShortDescription(); $raw_long_description = $docblock->getLongDescription()->getContents(); - $fences = get_docblock_code_fences( $raw_long_description ); - validate_docblock_setup_blueprint_scope( $fences, $inherited_setup_blueprints ); + $source_docblock = null; + + if ( false !== strpos( $short_description, '```' ) || false !== strpos( $raw_long_description, '```' ) ) { + if ( $element instanceof File_Reflector ) { + $location = $docblock->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 ) { + // phpDocumentor treats any line beginning with `@` as a tag, even + // inside a fenced block, and splits its short description at paragraph + // boundaries inside fences. Recover the description directly from the + // source comment so runnable code retains its source line structure. Stop + // at the first real tag outside a fence. Keep track of tag-looking code + // lines so the parsed DocBlock tags below can omit phpDocumentor's false + // positives without hiding real tags with the same name. + $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 ); + } + + $description_lines = array(); + $closing_pattern = null; + $open_fence_tag_count = null; + $first_open_fence_tag_line = null; + // phpDocumentor starts its tag block at an optionally indented @ followed + // by a letter. Once started, only a column-zero @ with any valid tag name + // opens another tag; indented lines extend the preceding tag instead. + $parsed_tag_block_started = false; + foreach ( $source_lines as $source_line ) { + if ( null === $closing_pattern ) { + if ( preg_match( '/^[ \t]*(`{3,})[^`]*$/', $source_line, $opening ) ) { + $closing_pattern = '/^[ \t]*' . preg_quote( $opening[1], '/' ) . '[ \t]*$/'; + $open_fence_tag_count = count( $fenced_docblock_tag_names ); + $first_open_fence_tag_line = null; + } elseif ( + ( ! $parsed_tag_block_started && preg_match( '/^[ \t]*@\pL/u', $source_line ) ) || + ( $parsed_tag_block_started && preg_match( '/^@[\w\-_\\\\]+/u', $source_line ) ) + ) { + break; + } + } elseif ( preg_match( $closing_pattern, $source_line ) ) { + $closing_pattern = null; + $open_fence_tag_count = null; + $first_open_fence_tag_line = null; + } else { + $tag_name = null; + if ( ! $parsed_tag_block_started && preg_match( '/^[ \t]*@([\pL][\w\-_\\\\]*)/u', $source_line, $tag_match ) ) { + $parsed_tag_block_started = true; + $tag_name = $tag_match[1]; + } elseif ( $parsed_tag_block_started && preg_match( '/^@([\w\-_\\\\]+)/u', $source_line, $tag_match ) ) { + $tag_name = $tag_match[1]; + } + + if ( null !== $tag_name ) { + if ( null === $first_open_fence_tag_line ) { + $first_open_fence_tag_line = count( $description_lines ); + } + $fenced_docblock_tag_names[] = $tag_name; + } + } + + $description_lines[] = $source_line; + } + if ( null !== $closing_pattern && null !== $first_open_fence_tag_line ) { + $description_lines = array_slice( $description_lines, 0, $first_open_fence_tag_line ); + $fenced_docblock_tag_names = array_slice( $fenced_docblock_tag_names, 0, $open_fence_tag_count ); + } + // 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 ); - $setup_blueprints = array_merge( - get_referenced_setup_blueprints( $code_snippets, $inherited_setup_blueprints ), - $setup_blueprints - ); - validate_docblock_setup_blueprint_references( $code_snippets, $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(), @@ -232,7 +427,7 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), } $output = array( - 'description' => preg_replace( '/[\n\r]+/', ' ', $docblock->getShortDescription() ), + 'description' => preg_replace( '/[\n\r]+/', ' ', $short_description ), 'long_description' => format_long_description( strip_docblock_code_snippet_fences( $raw_long_description, $fences ) ), 'tags' => array(), ); @@ -244,7 +439,13 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), $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() ) ), @@ -281,6 +482,45 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), return $output; } +/** + * Blanks complete fenced bodies before phpDocumentor parses a raw DocBlock. + * + * phpDocumentor has no fence state and may reject valid PHP lines as malformed + * tags. Opening and closing lines remain so export_docblock() still knows to + * recover the original source after this sanitized comment has supplied the + * real tags outside the fences. + * + * @param string $source_docblock Raw DocBlock including comment delimiters. + * + * @return string + */ +function sanitize_docblock_fenced_contents( $source_docblock ) { + $original_source_docblock = $source_docblock; + $source_docblock = preg_replace( "/\r\n?/", "\n", $source_docblock ); + $contents = preg_replace( '/\A[ \t]*\/\*\*[ \t]?/', '', $source_docblock ); + $contents = preg_replace( '/[ \t]*\*\/[ \t]*\z/', '', $contents ); + $content_lines = explode( "\n", $contents ); + foreach ( $content_lines as $key => $line ) { + $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 the DocBlock's decorative `*`, but no text that phpDocumentor + // could reinterpret as a tag or part of the surrounding description. + $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. * @@ -432,6 +672,65 @@ function export_methods( array $methods, array $inherited_setup_blueprints = arr * @return array */ function get_docblock_code_fences( $text ) { + $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 ); @@ -461,52 +760,41 @@ function get_docblock_code_fences( $text ) { $code_lines = array_slice( $lines, $line_no + 1, $end - $line_no - 1 ); if ( '' !== $indent ) { - // Strip the opening fence's indentation from each content line. + // 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 ) { - if ( 0 === strpos( $code_line, $indent ) ) { - $code_lines[ $key ] = substr( $code_line, strlen( $indent ) ); + $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 ); } } } - $language = trim( $opening[3] ); + $info = trim( $opening[3] ); + $language = $info; if ( preg_match( '/^\S+/', $language, $language_matches ) ) { $language = $language_matches[0]; } $fence = array( 'language' => $language, - 'info' => trim( $opening[3] ), + 'info' => $info, 'code' => rtrim( implode( "\n", $code_lines ), "\n" ), 'start' => $line_no, 'end' => $end, ); - // Classify each fence once here so the snippet exporter and the - // description stripper share the result instead of recomputing it. - $info_parts = get_docblock_fence_info_parts( $fence ); - $fence['referenced_setup'] = get_docblock_referenced_blueprint_name( $fence ); - $fence['is_interactive_php'] = 'php' === $fence['language'] - && isset( $info_parts[1] ) - && 'interactive' === $info_parts[1] - && ( 2 === count( $info_parts ) || null !== $fence['referenced_setup'] ); - $fence['is_expected_output'] = is_docblock_expected_output_fence( $fence ); - $fence['is_blueprint'] = is_docblock_blueprint_fence( $fence ); - $fence['setup_name'] = get_docblock_setup_blueprint_name( $fence ); - $fence['is_code_snippet'] = $fence['is_interactive_php'] || $fence['is_expected_output'] || $fence['is_blueprint'] || null !== $fence['setup_name']; - $fences[] = $fence; $line_no = $end; } - // 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; } @@ -522,10 +810,11 @@ function get_docblock_code_fences( $text ) { * 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 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 a setup Blueprint is not a valid JSON object. + * @throws \InvalidArgumentException When snippet metadata is invalid, ambiguous, or unattached. * * @return array */ @@ -712,7 +1001,8 @@ function docblock_fences_have_only_whitespace_between( $first, $second, $lines ) * 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 string $text Raw DocBlock long description. + * @param array|null $fences Optional. Classified fences already validated by the snippet exporter. * * @return string */ @@ -730,13 +1020,16 @@ function strip_docblock_code_snippet_fences( $text, $fences = null ) { continue; } - // Interactive PHP fences become `code_snippets` entries; replace each one with an - // inline placeholder, keyed by the fence's shared snippet index, so the - // theme renders the runnable snippet in place between the surrounding - // prose. Snippet-metadata fences (expected-output, Blueprints) are removed. + // 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'] ) { - $replace_lines[ $i ] = docblock_code_snippet_placeholder( $fence['snippet_index'] ); + // 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; } @@ -754,119 +1047,6 @@ function strip_docblock_code_snippet_fences( $text, $fences = null ) { return trim( implode( "\n", $lines ) ); } -/** - * Inline placeholder left in `long_description` for the Nth PHP code snippet. - * - * A plain HTML comment so it survives Markdown rendering, `the_content`, and the - * block parser untouched; the theme replaces it with the rendered runnable - * snippet, keeping snippets positioned between the surrounding prose. - * - * @param int $index Zero-based index into `code_snippets`. - * @return string - */ -function docblock_code_snippet_placeholder( $index ) { - return ''; -} - -/** - * 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 ( ! isset( $snippet['blueprint'] ) || ! is_string( $snippet['blueprint'] ) ) { - continue; - } - - if ( array_key_exists( $snippet['blueprint'], $setup_blueprints ) ) { - $referenced_setup_blueprints[ $snippet['blueprint'] ] = $setup_blueprints[ $snippet['blueprint'] ]; - } - } - - return $referenced_setup_blueprints; -} - -/** - * Rejects snippet references that do not resolve to an available setup Blueprint. - * - * @param array $snippets Exported code snippets. - * @param array $setup_blueprints Setup Blueprints available to the DocBlock. - * @param array $fences Optional parsed fences used to identify unresolved references. - * - * @throws \InvalidArgumentException When a snippet references an undefined setup Blueprint. - */ -function validate_docblock_setup_blueprint_references( $snippets, $setup_blueprints, $fences = array() ) { - $snippet_lines = array(); - foreach ( $fences as $fence ) { - if ( $fence['is_interactive_php'] ) { - $snippet_lines[ $fence['snippet_index'] ] = $fence['start'] + 1; - } - } - - foreach ( $snippets as $index => $snippet ) { - if ( - isset( $snippet['blueprint'] ) && - is_string( $snippet['blueprint'] ) && - ! array_key_exists( $snippet['blueprint'], $setup_blueprints ) - ) { - $location = isset( $snippet_lines[ $index ] ) - ? ' referenced on line ' . $snippet_lines[ $index ] . ' of the long description' - : ''; - throw new \InvalidArgumentException( 'Setup Blueprint "' . $snippet['blueprint'] . '"' . $location . ' is not defined.' ); - } - } -} - -/** - * Rejects local setup Blueprint definitions that shadow an enclosing DocBlock. - * - * @param array $fences Parsed DocBlock fences. - * @param array $inherited_setup_blueprints Setup Blueprints inherited from enclosing DocBlocks. - * - * @throws \InvalidArgumentException When a local definition reuses an inherited name. - */ -function validate_docblock_setup_blueprint_scope( $fences, $inherited_setup_blueprints ) { - 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.' - ); - } - } -} - -/** - * Checks whether a parsed DocBlock fence contains snippet expected output. - * - * @param array $fence - * - * @return bool - */ -function is_docblock_expected_output_fence( $fence ) { - return 'expected-output' === $fence['language'] && 1 === count( get_docblock_fence_info_parts( $fence ) ); -} - -/** - * Checks whether a parsed DocBlock fence contains a WordPress Playground Blueprint. - * - * @param array $fence - * - * @return bool - */ -function is_docblock_blueprint_fence( $fence ) { - return 'setup-blueprint' === $fence['language'] && 1 === count( get_docblock_fence_info_parts( $fence ) ); -} - /** * Decodes a Blueprint fence into the structure exported to JSON. * @@ -942,87 +1122,28 @@ function preserve_json_object_shapes( &$value ) { return $value; } -/** - * 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 ( 'setup-blueprint' === $fence['language'] && 2 === count( $info_parts ) ) { - validate_docblock_setup_blueprint_name( $info_parts[1], $fence ); - return $info_parts[1]; - } - - 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 ) { - $info_parts = get_docblock_fence_info_parts( $fence ); - - if ( - 'php' === $fence['language'] && - 3 === count( $info_parts ) && - 'interactive' === $info_parts[1] && - 0 === strpos( $info_parts[2], 'setup-blueprint=' ) - ) { - $name = substr( $info_parts[2], strlen( 'setup-blueprint=' ) ); - validate_docblock_setup_blueprint_name( $name, $fence ); - return $name; - } - - return null; -} - /** * 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 array $fence Parsed fence used to identify invalid input. + * @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, $fence ) { +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 ' . ( $fence['start'] + 1 ) . + 'Setup Blueprint name "' . $name . '" on line ' . ( $line_no + 1 ) . ' of the long description must be lowercase kebab-case starting with a letter.' ); } -/** - * 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. * @@ -1106,6 +1227,15 @@ function format_long_description( $description ) { $description = $parsedown->text( $description ); } + // Fences may use more than three leading spaces. Outside a list, Parsedown + // treats their generated placeholder as indented code. Restore only the exact + // internal marker so the theme can still replace it with the runnable snippet. + $description = preg_replace( + '#
[ \t]*<!-- wp-parser-code-snippet:([0-9]+) -->
#', + '', + $description + ); + $description = fix_newlines( $description ); return $description; diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 9ab2b8bb..27624632 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -56,6 +56,8 @@ class Test_Class { * * ```php interactive setup-blueprint=file-greeting * 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'] + ); + } + /** * Test that function docs are exported. */ @@ -462,6 +558,26 @@ public function test_code_snippet_trims_trailing_blank_lines() { $this->assertEquals( "assertEquals( + "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 ) + ); + } + + /** + * 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 snippet metadata fences do not accept extra arguments. */ @@ -866,17 +1065,15 @@ public function test_duplicate_setup_blueprint_name_fails() { */ public function test_setup_blueprint_name_cannot_shadow_inherited_definition() { - $fences = \WP_Parser\get_docblock_code_fences( - "Introductory prose.\n```setup-blueprint shared\n{}\n```" - ); + $file = __DIR__ . '/shadowed-blueprint.inc'; $this->expectException( \InvalidArgumentException::class ); - $this->expectExceptionMessage( 'Setup Blueprint "shared" on line 2 of the long description is already defined in an enclosing DocBlock.' ); - - \WP_Parser\validate_docblock_setup_blueprint_scope( - $fences, - array( 'shared' => array( 'steps' => array() ) ) + $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__ ); } /** @@ -900,52 +1097,6 @@ public function test_invalid_setup_blueprint_error_identifies_fence() { ); } - /** - * Test that named setup Blueprint references must resolve in the DocBlock scope. - */ - public function test_undefined_setup_blueprint_reference_fails() { - - $this->expectException( \InvalidArgumentException::class ); - $this->expectExceptionMessage( 'Setup Blueprint "missing" is not defined.' ); - - \WP_Parser\validate_docblock_setup_blueprint_references( - array( - array( - 'type' => 'php-code-snippet', - 'code' => ' 'missing', - ), - ), - array() - ); - } - - /** - * Test that inline and inherited setup Blueprints satisfy validation. - */ - public function test_setup_blueprint_reference_validation_accepts_available_blueprints() { - - \WP_Parser\validate_docblock_setup_blueprint_references( - array( - array( - 'type' => 'php-code-snippet', - 'code' => ' array( 'steps' => array() ), - ), - array( - 'type' => 'php-code-snippet', - 'code' => ' 'inherited', - ), - ), - array( - 'inherited' => array( 'steps' => array() ), - ) - ); - - $this->assertTrue( true ); - } - /** * Test that one snippet cannot silently choose between multiple setup Blueprints. * @@ -1171,11 +1322,23 @@ public function test_property_docblocks() { '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' => array( 'file-greeting' => array( 'steps' => array( diff --git a/tests/phpunit/tests/export/fence-first-docblocks.inc b/tests/phpunit/tests/export/fence-first-docblocks.inc new file mode 100644 index 00000000..39a7249f --- /dev/null +++ b/tests/phpunit/tests/export/fence-first-docblocks.inc @@ -0,0 +1,63 @@ + array( '{}' ), + 'empty object' => array( '{}' ), + 'non-empty object' => array( '{"path":"wp-includes/version.php"}' ), 'null' => array( 'null' ), 'string' => array( '"parsed files"' ), 'number' => array( '42' ), @@ -51,6 +52,42 @@ public function test_import_rejects_malformed_json() { $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":[]}]' ), + '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; @@ -60,6 +97,33 @@ public function test_import_accepts_a_top_level_file_list() { $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 ); diff --git a/tests/phpunit/tests/import/file.php b/tests/phpunit/tests/import/file.php index 6ce0fb69..2b558909 100644 --- a/tests/phpunit/tests/import/file.php +++ b/tests/phpunit/tests/import/file.php @@ -171,4 +171,46 @@ public function test_function_snippet_metadata_imported_and_cleared() { $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 ) ); + } } From 7f95811676364992e44177077f061337e8d04c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 20 Jul 2026 16:16:26 +0200 Subject: [PATCH 07/13] Preserve adjacent indented snippet placeholders --- lib/runner.php | 40 +++++++++++-- tests/phpunit/tests/export/docblocks.php | 75 ++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/lib/runner.php b/lib/runner.php index ced2d196..d707541d 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -672,6 +672,12 @@ function export_methods( array $methods, array $inherited_setup_blueprints = arr * @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 ) { @@ -1029,7 +1035,7 @@ function strip_docblock_code_snippet_fences( $text, $fences = null ) { // 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 . ''; + $replace_lines[ $i ] = $indent . ''; } else { $remove_lines[ $i ] = true; } @@ -1227,11 +1233,35 @@ function format_long_description( $description ) { $description = $parsedown->text( $description ); } - // Fences may use more than three leading spaces. Outside a list, Parsedown - // treats their generated placeholder as indented code. Restore only the exact - // internal marker so the theme can still replace it with the runnable snippet. + /* + * 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 ) { + preg_match_all( + '/<!-- wp-parser-code-snippet-placeholder:([0-9]+) -->/', + $matches[1], + $placeholder_matches + ); + $placeholders = array(); + foreach ( $placeholder_matches[1] as $index ) { + $placeholders[] = ''; + } + + return implode( "\n", $placeholders ); + }, + $description + ); $description = preg_replace( - '#
[ \t]*<!-- wp-parser-code-snippet:([0-9]+) -->
#', + '//', '', $description ); diff --git a/tests/phpunit/tests/export/docblocks.php b/tests/phpunit/tests/export/docblocks.php index 1a69bb96..c4662154 100644 --- a/tests/phpunit/tests/export/docblocks.php +++ b/tests/phpunit/tests/export/docblocks.php @@ -674,8 +674,8 @@ public function test_code_snippet_inline_placeholders() { $stripped = \WP_Parser\strip_docblock_code_snippet_fences( $description ); // One placeholder per PHP fence, in document order, with the prose around them. - $first = strpos( $stripped, '' ); - $second = strpos( $stripped, '' ); + $first = strpos( $stripped, '' ); + $second = strpos( $stripped, '' ); $this->assertNotFalse( $first ); $this->assertNotFalse( $second ); $this->assertLessThan( $second, $first ); @@ -725,7 +725,7 @@ public function test_indented_code_snippet_placeholder_preserves_markdown_list( ); $stripped = \WP_Parser\strip_docblock_code_snippet_fences( $description ); - $this->assertStringContainsString( $indent . '', $stripped ); + $this->assertStringContainsString( $indent . '', $stripped ); $this->assertSame( $expected, \WP_Parser\format_long_description( $stripped ) @@ -768,13 +768,51 @@ public function test_standalone_indented_code_snippet_placeholder_remains_html( $indent . "```\n\nAfter"; $stripped = \WP_Parser\strip_docblock_code_snippet_fences( $description ); - $this->assertStringContainsString( $indent . '', $stripped ); + $this->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. */ @@ -787,6 +825,35 @@ public function standalone_code_snippet_indentation() { ); } + /** + * 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. */ From 48099d1469d67f11ffc1428682f9f77aca20f5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 20 Jul 2026 16:31:51 +0200 Subject: [PATCH 08/13] Reduce snippet parser duplication --- lib/class-command.php | 5 +- lib/class-file-reflector.php | 15 +- lib/runner.php | 111 +++--- tests/phpunit/tests/export/docblocks.php | 434 +++++++---------------- 4 files changed, 200 insertions(+), 365 deletions(-) diff --git a/lib/class-command.php b/lib/class-command.php index 0cd8b07e..a4720b2e 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] * diff --git a/lib/class-file-reflector.php b/lib/class-file-reflector.php index 8b4da3bb..64895501 100644 --- a/lib/class-file-reflector.php +++ b/lib/class-file-reflector.php @@ -45,27 +45,28 @@ class File_Reflector extends FileReflector { protected $last_doc = null; /** - * Whether the file DocBlock was parsed with its complete fence bodies blanked. + * Whether complete fence bodies were blanked before parsing the file DocBlock. * * @var bool */ protected $docblock_was_sanitized = false; /** - * Reports whether false in-fence tags were absent from the parsed DocBlock. + * Indicates whether complete file-DocBlock fence bodies were blanked. * - * @return bool + * @return bool Whether phpDocumentor parsed a sanitized comment. */ public function wasDocBlockSanitized() { return $this->docblock_was_sanitized; } /** - * Let phpDocumentor identify a file DocBlock without parsing fenced PHP as tags. + * Lets phpDocumentor identify a file DocBlock without parsing fenced PHP as tags. * - * FileReflector parses and removes the file comment before visiting individual - * nodes. Temporarily blanking complete fence bodies preserves that lifecycle; - * export_docblock() later recovers the untouched source from the file contents. + * `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 * diff --git a/lib/runner.php b/lib/runner.php index d707541d..05adf31f 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -40,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(); @@ -196,11 +201,19 @@ function ( $matches ) use ( $replacement_string ) { } /** - * @param BaseReflector|ReflectionAbstract $element - * @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. + * 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, array $inherited_setup_blueprints = array(), $source_file = '' ) { $node_docblock = null; @@ -291,53 +304,38 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), $source_lines[ $key ] = preg_replace( '/^[ \t]*\*[ \t]?/', '', $source_line ); } - $description_lines = array(); - $closing_pattern = null; - $open_fence_tag_count = null; - $first_open_fence_tag_line = null; + // 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(); // phpDocumentor starts its tag block at an optionally indented @ followed // by a letter. Once started, only a column-zero @ with any valid tag name // opens another tag; indented lines extend the preceding tag instead. - $parsed_tag_block_started = false; - foreach ( $source_lines as $source_line ) { - if ( null === $closing_pattern ) { - if ( preg_match( '/^[ \t]*(`{3,})[^`]*$/', $source_line, $opening ) ) { - $closing_pattern = '/^[ \t]*' . preg_quote( $opening[1], '/' ) . '[ \t]*$/'; - $open_fence_tag_count = count( $fenced_docblock_tag_names ); - $first_open_fence_tag_line = null; - } elseif ( - ( ! $parsed_tag_block_started && preg_match( '/^[ \t]*@\pL/u', $source_line ) ) || - ( $parsed_tag_block_started && preg_match( '/^@[\w\-_\\\\]+/u', $source_line ) ) - ) { + $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']; + + $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; } - } elseif ( preg_match( $closing_pattern, $source_line ) ) { - $closing_pattern = null; - $open_fence_tag_count = null; - $first_open_fence_tag_line = null; - } else { - $tag_name = null; - if ( ! $parsed_tag_block_started && preg_match( '/^[ \t]*@([\pL][\w\-_\\\\]*)/u', $source_line, $tag_match ) ) { - $parsed_tag_block_started = true; - $tag_name = $tag_match[1]; - } elseif ( $parsed_tag_block_started && preg_match( '/^@([\w\-_\\\\]+)/u', $source_line, $tag_match ) ) { - $tag_name = $tag_match[1]; - } - - if ( null !== $tag_name ) { - if ( null === $first_open_fence_tag_line ) { - $first_open_fence_tag_line = count( $description_lines ); - } - $fenced_docblock_tag_names[] = $tag_name; - } + $parsed_tag_block_started = true; + $fenced_docblock_tag_names[] = $tag_match[1]; } $description_lines[] = $source_line; } - if ( null !== $closing_pattern && null !== $first_open_fence_tag_line ) { - $description_lines = array_slice( $description_lines, 0, $first_open_fence_tag_line ); - $fenced_docblock_tag_names = array_slice( $fenced_docblock_tag_names, 0, $open_fence_tag_count ); - } // 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. @@ -496,11 +494,18 @@ function export_docblock( $element, array $inherited_setup_blueprints = array(), */ 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 ); } @@ -512,8 +517,7 @@ function sanitize_docblock_fenced_contents( $source_docblock ) { $source_lines = explode( "\n", $source_docblock ); foreach ( $fences as $fence ) { for ( $line = $fence['start'] + 1; $line < $fence['end']; $line++ ) { - // Retain the DocBlock's decorative `*`, but no text that phpDocumentor - // could reinterpret as a tag or part of the surrounding description. + // Retain indentation and the decorative star while blanking body text. $source_lines[ $line ] = preg_match( '/^([ \t]*\*)/', $source_lines[ $line ], $prefix ) ? $prefix[1] : ''; } } @@ -1246,17 +1250,8 @@ function format_long_description( $description ) { $description = preg_replace_callback( '#
((?:[ \t\n]*+<!-- wp-parser-code-snippet-placeholder:[0-9]+ -->)+[ \t\n]*+)
#', function ( $matches ) { - preg_match_all( - '/<!-- wp-parser-code-snippet-placeholder:([0-9]+) -->/', - $matches[1], - $placeholder_matches - ); - $placeholders = array(); - foreach ( $placeholder_matches[1] as $index ) { - $placeholders[] = ''; - } - - return implode( "\n", $placeholders ); + $placeholders = str_replace( array( '<', '>' ), array( '<', '>' ), trim( $matches[1] ) ); + return preg_replace( '/[ \t\n]++(?=