Skip to content
Open
180 changes: 161 additions & 19 deletions lib/runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 ) .
Expand All @@ -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<int, array{text: string, start: int, line_start: int}> 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(
"<?php\n" . $code,
new \PhpParser\ErrorHandler\Collecting()
);
array_shift( $tokens );
array_pop( $tokens );

$comments = array();
$offset = 0;
$line_start = 0;
foreach ( $tokens as $token ) {
$id = $token->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.
*
Expand Down Expand Up @@ -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
Expand Down
31 changes: 7 additions & 24 deletions tests/phpunit/tests/export/docblocks.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,10 +104,8 @@ class Test_Class {
* <?php
* require '/wordpress/wp-load.php';
* echo docs_fixture_greeting();
* ```
*
* ```expected-output
* Hello from a method
* // Outputs:
* // Hello from a method
* ```
*
* @since 6.9.0
Expand All @@ -137,20 +132,14 @@ class Test_Class {
* <?php
* require '/wordpress/wp-load.php';
* echo docs_shared_greeting( 'first' );
* ```
*
* ```expected-output
* Hello, first
* // Outputs: Hello, first
* ```
*
* ```php interactive setup-blueprint=shared-greeting
* <?php
* require '/wordpress/wp-load.php';
* echo docs_shared_greeting( 'second' );
* ```
*
* ```expected-output
* Hello, second
* // Outputs: Hello, second
* ```
*
* @since 6.9.0
Expand All @@ -165,10 +154,7 @@ class Test_Class {
* <?php
* require '/wordpress/wp-load.php';
* echo docs_file_greeting();
* ```
*
* ```expected-output
* Hello from the file setup
* // Outputs: Hello from the file setup
* ```
*
* @since 6.9.0
Expand All @@ -194,10 +180,7 @@ $var = apply_filters_ref_array( 'test_ref_array_filter', array( &$var ) );
* <?php
* require '/wordpress/wp-load.php';
* echo docs_file_greeting();
* ```
*
* ```expected-output
* Hello from the file setup
* // Outputs: Hello from the file setup
* ```
*
* @since 3.7.0
Expand Down
Loading