diff --git a/lib/runner.php b/lib/runner.php index 70e77d9..296b585 100644 --- a/lib/runner.php +++ b/lib/runner.php @@ -811,14 +811,12 @@ function get_docblock_code_fences( $text ) { 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 ); + $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; + $fences[ $key ]['is_code_snippet'] = $is_interactive_php || $is_blueprint || null !== $setup_name; } // Number the interactive PHP fences so the exporter and the stripper agree on each @@ -996,6 +994,11 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence 'type' => 'php-code-snippet', 'code' => $fences[ $i ]['code'], ); + $output_comment = extract_docblock_php_snippet_output_comment( $snippet['code'] ); + $snippet['code'] = $output_comment['code']; + if ( null !== $output_comment['expected_output'] ) { + $snippet['expected_output'] = $output_comment['expected_output']; + } if ( null !== $fences[ $i ]['referenced_setup'] ) { $snippet['blueprint'] = $fences[ $i ]['referenced_setup']; @@ -1029,13 +1032,6 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence break; } - if ( $fences[ $j ]['is_expected_output'] ) { - // First expected-output fence ends the run, so a snippet takes one. - $snippet['expected_output'] = $fences[ $j ]['code']; - $consumed_fences[ $j ] = true; - break; - } - if ( null !== $fences[ $j ]['setup_name'] ) { break; } @@ -1067,13 +1063,6 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence 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 ) . @@ -1085,6 +1074,159 @@ function export_docblock_code_snippets( $text, &$setup_blueprints = null, $fence return $snippets; } +/** + * Extracts trailing Outputs metadata from an interactive PHP snippet. + * + * `// Outputs:` declares literal output. Text on the same line is a one-line + * value. An empty header starts a block that continues through the final + * consecutive `//` comment lines. One space after each `//` is a comment + * delimiter, while any additional indentation becomes part of the output. A + * final empty comment line preserves a final output newline. + * + * `// Outputs (JSON-encoded):` declares one JSON string. This explicit form + * makes trailing whitespace and escaped characters visible without assigning + * special meaning to any literal output text. + * + * @param string $code Snippet code extracted from a DocBlock fence. + * + * @throws \InvalidArgumentException When an Outputs (JSON-encoded) comment is not a JSON string. + * + * @return array{code: string, expected_output: string|null} Runnable code and its optional output. + */ +function extract_docblock_php_snippet_output_comment( $code ) { + if ( false === strpos( $code, '// Outputs' ) ) { + return array( + 'code' => $code, + 'expected_output' => null, + ); + } + + $comments = parse_trailing_docblock_php_snippet_line_comments( $code ); + if ( empty( $comments ) ) { + return array( + 'code' => $code, + 'expected_output' => null, + ); + } + + foreach ( $comments as $comment_index => $comment ) { + $value = substr( $comment['text'], 2 ); + if ( ' Outputs:' !== rtrim( $value, " \t" ) ) { + continue; + } + + $output_lines = array(); + foreach ( array_slice( $comments, $comment_index + 1 ) as $output_comment ) { + $value = substr( $output_comment['text'], 2 ); + if ( 0 === strpos( $value, ' ' ) ) { + $value = substr( $value, 1 ); + } + $output_lines[] = $value; + } + + return array( + 'code' => rtrim( substr( $code, 0, $comment['line_start'] ), "\n" ), + 'expected_output' => implode( "\n", $output_lines ), + ); + } + + $comment = end( $comments ); + $value = substr( $comment['text'], 2 ); + if ( 0 === strpos( $value, ' Outputs:' ) ) { + $output = substr( $value, strlen( ' Outputs:' ) ); + if ( 0 === strpos( $output, ' ' ) ) { + $output = substr( $output, 1 ); + } + + return array( + 'code' => rtrim( substr( $code, 0, $comment['line_start'] ), "\n" ), + 'expected_output' => $output, + ); + } + + if ( 0 !== strpos( $value, ' Outputs (JSON-encoded):' ) ) { + return array( + 'code' => $code, + 'expected_output' => null, + ); + } + + $output = substr( $value, strlen( ' Outputs (JSON-encoded):' ) ); + if ( 0 === strpos( $output, ' ' ) ) { + $output = substr( $output, 1 ); + } + + $decoded = json_decode( trim( $output ), true ); + if ( JSON_ERROR_NONE !== json_last_error() || ! is_string( $decoded ) ) { + throw new \InvalidArgumentException( + 'The Outputs (JSON-encoded) comment must contain one JSON string.' + ); + } + + return array( + 'code' => rtrim( substr( $code, 0, $comment['line_start'] ), "\n" ), + 'expected_output' => $decoded, + ); +} + +/** + * Parses the consecutive standalone PHP line comments at the end of a snippet. + * + * @param string $code Snippet code extracted from a DocBlock fence. + * + * @return array Comments in source order. + */ +function parse_trailing_docblock_php_snippet_line_comments( $code ) { + // PHP-Parser's emulative lexer normalizes token shapes across PHP versions. + // Prefixing forces snippets without an opening tag into PHP mode. + $lexer = new \PhpParser\Lexer\Emulative(); + $tokens = $lexer->tokenize( + "id; + $text = $token->text; + $is_standalone_line_comment = + T_COMMENT === $id && + 0 === strpos( $text, '//' ) && + '' === trim( substr( $code, $line_start, $offset - $line_start ), " \t" ); + + if ( $is_standalone_line_comment ) { + if ( ! empty( $comments ) ) { + $previous = end( $comments ); + $previous_end = $previous['start'] + strlen( $previous['text'] ); + $separator = substr( $code, $previous_end, $line_start - $previous_end ); + if ( 1 !== substr_count( $separator, "\n" ) || '' !== trim( $separator, " \t\n" ) ) { + $comments = array(); + } + } + + $comments[] = array( + 'text' => $text, + 'start' => $offset, + 'line_start' => $line_start, + ); + } elseif ( T_WHITESPACE !== $id ) { + $comments = array(); + } + + $last_newline = strrpos( $text, "\n" ); + if ( false !== $last_newline ) { + $line_start = $offset + $last_newline + 1; + } + $offset += strlen( $text ); + } + + return $comments; +} + /** * Checks whether two fences are separated only by blank DocBlock lines. * @@ -1136,7 +1278,7 @@ function strip_docblock_code_snippet_fences( $text, $fences = null ) { // 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. + // Snippet-metadata fences containing Blueprints are removed. for ( $i = $fence['start']; $i <= $fence['end']; $i++ ) { if ( $fence['is_interactive_php'] && $i === $fence['start'] ) { // Keep a nested fence's indentation so Markdown leaves the replacement diff --git a/tests/phpunit/tests/export/docblocks.inc b/tests/phpunit/tests/export/docblocks.inc index 2762463..fbafe08 100644 --- a/tests/phpunit/tests/export/docblocks.inc +++ b/tests/phpunit/tests/export/docblocks.inc @@ -60,10 +60,7 @@ class Test_Class { * @unlink( '/tmp/phpdoc-parser-property' ); * require '/wordpress/wp-load.php'; * echo docs_file_greeting(); - * ``` - * - * ```expected-output - * Hello from the file setup + * // Outputs: Hello from the file setup * ``` * * @since 3.0.0 @@ -107,10 +104,8 @@ class Test_Class { * assertCount( 1, $snippets ); $this->assertSame( $expected_code, $snippets[0]['code'] ); - $this->assertSame( $expected_output, $snippets[0]['expected_output'] ); } public function code_snippet_fence_delimiters() { return array( 'smaller runs stay inside a larger fence' => array( - "````php interactive\n array( - "```php interactive\n array( - " ```php interactive\n array( - " ```php interactive\n" );', + '$p->next_tag();', + 'foreach ( $p->class_list() as $class_name ) {', + ' echo "{$class_name} ";', + '}', + '// Outputs (JSON-encoded): "free lang-en "', + '```', + ) + ) + ); + + $this->assertSame( + array( + array( + 'type' => 'php-code-snippet', + 'code' => "\$p = WP_HTML_Processor::create_fragment( \"
\" );\n" . + "\$p->next_tag();\n" . + "foreach ( \$p->class_list() as \$class_name ) {\n" . + " echo \"{\$class_name} \";\n" . + '}', + 'expected_output' => 'free lang-en ', + ), + ), + $snippets + ); + } + + /** + * Test that text after Outputs is exported as a raw one-line value. + */ + public function test_inline_code_snippet_output_comment() { + + $snippets = \WP_Parser\export_docblock_code_snippets( + "```php interactive\necho esc_html( '' );\n// Outputs: \n```" + ); + + $this->assertSame( + array( + array( + 'type' => 'php-code-snippet', + 'code' => "echo esc_html( '' );", + 'expected_output' => '', + ), + ), + $snippets + ); + } + + /** + * Test that quotes in literal one-line output remain output text. + */ + public function test_inline_code_snippet_output_comment_preserves_quotes() { + + $snippets = \WP_Parser\export_docblock_code_snippets( + "```php interactive\necho 'example';\n// Outputs: \"second \"\n```" + ); + + $this->assertSame( '"second "', $snippets[0]['expected_output'] ); + } + + /** + * Test that a trailing Outputs comment block exports human-readable output. + */ + public function test_multiline_code_snippet_output_comment() { + + $snippets = \WP_Parser\export_docblock_code_snippets( + implode( + "\n", + array( + '```php interactive', + '$values = array(', + "\t'fruit' => 'apple',", + ');', + 'print_r( $values );', + '// Outputs:', + '// Array', + '// (', + '// [fruit] => apple', + '// )', + '//', + '```', + ) + ) + ); + + $this->assertSame( + array( + array( + 'type' => 'php-code-snippet', + 'code' => "\$values = array(\n\t'fruit' => 'apple',\n);\nprint_r( \$values );", + 'expected_output' => "Array\n(\n [fruit] => apple\n)\n", + ), + ), + $snippets + ); + } + + /** + * Test that a human-readable output block preserves literal Unicode text. + */ + public function test_multiline_code_snippet_output_comment_preserves_unicode() { + + $snippets = \WP_Parser\export_docblock_code_snippets( + "```php interactive\necho 'done';\n// Outputs:\n// ✅ Complete\n```" + ); + + $this->assertSame( '✅ Complete', $snippets[0]['expected_output'] ); + } + + /** + * Test that literal output preserves quotes and trailing newlines. + */ + public function test_multiline_code_snippet_output_comment_preserves_literal_text() { + + $snippets = \WP_Parser\export_docblock_code_snippets( + "```php interactive\necho 'done';\n// Outputs:\n// first\n// \"second \"\n//\n//\n```" + ); + + $this->assertSame( "first\n\"second \"\n\n", $snippets[0]['expected_output'] ); + } + + /** + * Test that JSON-encoded Outputs comments retain their output value. + * + * @dataProvider json_encoded_code_snippet_output_comments + */ + public function test_json_encoded_code_snippet_output_comments( $comment, $expected_output ) { + + $snippets = \WP_Parser\export_docblock_code_snippets( + "```php interactive\necho 'example';\n" . $comment . "\n```" + ); + + $this->assertSame( + array( + array( + 'type' => 'php-code-snippet', + 'code' => "echo 'example';", + 'expected_output' => $expected_output, + ), + ), + $snippets + ); + } + + /** + * Returns JSON-encoded output comments with formatting that must survive export. + */ + public function json_encoded_code_snippet_output_comments() { + + return array( + 'JSON string preserves trailing whitespace' => array( + '// Outputs (JSON-encoded): "ends with a space "', + 'ends with a space ', + ), + 'JSON string preserves multiline output with a trailing newline' => array( + '// Outputs (JSON-encoded): "first\\nsecond\\n"', + "first\nsecond\n", + ), + 'escaped quotes and tabs' => array( + '// Outputs (JSON-encoded): "A \\"quote\\" and a \\t tab"', + "A \"quote\" and a \t tab", + ), + 'whitespace after the JSON string is not output' => array( + '// Outputs (JSON-encoded): "done" ', + 'done', + ), + 'literal Unicode remains readable' => array( + '// Outputs (JSON-encoded): "✅ Complete "', + '✅ Complete ', + ), + ); + } + + /** + * Test that an Outputs comment before further code remains part of the snippet. + */ + public function test_non_trailing_code_snippet_output_comment_remains_code() { + + $snippets = \WP_Parser\export_docblock_code_snippets( + "```php interactive\necho 'before';\n// Outputs: \"before\"\necho 'after';\n```" + ); + + $this->assertSame( + array( + array( + 'type' => 'php-code-snippet', + 'code' => "echo 'before';\n// Outputs: \"before\"\necho 'after';", + ), + ), + $snippets + ); + } + + /** + * Test that Outputs text is metadata only in a trailing standalone line comment. + * + * @dataProvider php_code_containing_non_metadata_outputs_text + */ + public function test_php_code_containing_non_metadata_outputs_text( $code ) { + + $this->assertSame( + array( + array( + 'type' => 'php-code-snippet', + 'code' => $code, + ), + ), + \WP_Parser\export_docblock_code_snippets( "```php interactive\n" . $code . "\n```" ) + ); + } + + /** + * Returns PHP tokens in which Outputs text is ordinary program text. + */ + public function php_code_containing_non_metadata_outputs_text() { + + return array( + 'string literal' => array( "echo '// Outputs: not metadata';" ), + 'heredoc body' => array( "echo << array( "echo 'done';\n/* // Outputs: not metadata */" ), + 'comment after code on the same line' => array( "echo 'done'; // Outputs: not metadata" ), + 'ordinary line comment' => array( '// Example containing // Outputs: not metadata' ), + 'text after a PHP closing tag' => array( "\n// Outputs: not PHP" ), + ); + } + + /** + * Test that a JSON-encoded Outputs comment must contain a JSON string. + * + * @dataProvider invalid_json_encoded_code_snippet_output_comments + */ + public function test_json_encoded_code_snippet_output_comment_requires_json_string( $output ) { + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The Outputs (JSON-encoded) comment must contain one JSON string.' ); + + \WP_Parser\export_docblock_code_snippets( + "```php interactive\necho 'one';\n// Outputs (JSON-encoded): " . $output . "\n```" + ); + } + + /** + * Returns invalid JSON-encoded output values. + */ + public function invalid_json_encoded_code_snippet_output_comments() { + + return array( + 'malformed JSON' => array( '"one' ), + 'JSON value is not a string' => array( '1' ), + ); + } + /** * Test that unsupported info strings remain ordinary documentation. * @@ -540,6 +797,7 @@ public function unrecognized_code_fence_info_strings() { 'collapsed Blueprint reference' => array( 'php interactive setupblueprint=shared', $php ), 'unsupported interactive option' => array( 'php interactive editable=false', $php ), 'output alias' => array( 'output', 'output' ), + 'expected output fence' => array( 'expected-output', 'output' ), 'underscored expected output' => array( 'expected_output', 'output' ), 'typed expected output' => array( 'text/expected-output', 'output' ), 'uppercase PHP' => array( 'PHP interactive', $php ), @@ -557,8 +815,8 @@ public function unrecognized_code_fence_info_strings() { /** * 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. + * of collapsing every snippet to the end of the description. Setup Blueprint + * fences are removed. */ public function test_code_snippet_inline_placeholders() { @@ -577,10 +835,7 @@ public function test_code_snippet_inline_placeholders() { '```php interactive', '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. + // No raw PHP fence is left behind in the description. $this->assertStringNotContainsString( '```', $stripped ); $this->assertStringNotContainsString( 'assertEquals( array( @@ -793,9 +1042,6 @@ public function test_code_snippet_metadata_rejects_extra_arguments() { 'assertEquals( - array( - array( - 'type' => 'php-code-snippet', - 'code' => " 'First', - ), - array( - 'type' => 'php-code-snippet', - 'code' => " array( - 'steps' => array( - array( - 'step' => 'writeFile', - 'path' => '/tmp/second.php', - 'data' => ' 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```" ), ); } @@ -1149,16 +1342,12 @@ public function test_code_snippet_named_setup_blueprints() { '```php interactive setup-blueprint=shared', '