Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7d19fa7
Initial work on Native Markup Expressions
imliam Jul 7, 2026
c878e01
Additional clarifications
imliam Jul 9, 2026
3b9ac2b
Remove named slots (preferring straight attributes) and add lazy slots
imliam Jul 9, 2026
817b009
Reuse existing html entities table
imliam Jul 9, 2026
60ea026
Remove benchmarks from branch
imliam Jul 9, 2026
1ca6ad5
Remove bare function resolution
imliam Jul 9, 2026
4c1dfd8
Remove DOM interop
imliam Jul 9, 2026
7426098
Remove built-in transpile and markup2php function (only used for debu…
imliam Jul 9, 2026
dca0a1f
Consolidate tests into fewer, more sensible test files
imliam Jul 9, 2026
1288f26
Refactor zend_markup_normalize_text to simplify line handling and red…
imliam Jul 9, 2026
12703ab
Reuse label for markup expressions in the scanner
imliam Jul 9, 2026
8df3ca3
Improve handling of mismatched tags
imliam Jul 9, 2026
770984a
Refine operand position logic
imliam Jul 9, 2026
df72032
Refactor __toString() methods to share common logic
imliam Jul 9, 2026
18ed64c
Refactor attribute handling in html_append_attribute to use a dedicat…
imliam Jul 9, 2026
79e4190
Validate component prop keys in a single pass
imliam Jul 9, 2026
768dc33
Remove RFC references
imliam Jul 9, 2026
06ba401
Rename extension from "html" to "markup" so it is more open to future
imliam Jul 13, 2026
c210700
Add notes about XML
imliam Jul 14, 2026
72241d6
Change `:lazy` to `#lazy`
imliam Jul 14, 2026
457faf7
Lower amount of nesting for CI
imliam Jul 14, 2026
c60012e
Type checking on unregister hooks
imliam Jul 14, 2026
38bfc84
Remove backslashes breaking re2c Windows builds
imliam Jul 15, 2026
1b72fda
Regenerate credits_ext.h with correct extension name
imliam Jul 15, 2026
d9c6fd1
Skip inheritance cache for classes with injected __toString
imliam Jul 15, 2026
aa79823
Replace get_class($e) with $e::class
imliam Jul 15, 2026
4d3f91d
Replace "\n" with PHP_EOL
imliam Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions Zend/tests/markup_operand_position_bc.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
--TEST--
Native markup: bare "<" after operand-ending tokens stays a comparison (no-space BC)
--FILE--
<?php
// Postfix ++/-- end an operand: "<" is a comparison, not markup.
$i = 0; $n = 5;
$a = [1, 2, 3];
var_dump($i++<PHP_INT_MAX);
var_dump($i--<$n);
$count = 0;
while ($count++<count($a)) {}
var_dump($count);

// The `class` keyword in ::class ends an operand too.
const FOO = "zzz";
var_dump(\stdClass::class<FOO);
var_dump(self_name()<FOO);
function self_name(): string { return "aaa"; }

// Plain constants / qualified names end an operand.
var_dump(FOO<"zzzz");
var_dump(PHP_INT_MAX<FOO);

// `]` (array dereference) and `}` end an operand.
var_dump($a[0]<FOO);
var_dump(match(true) { default => 1 }<FOO);

// A closing heredoc label ends an operand.
var_dump(<<<EOT
aaa
EOT<FOO);

// Interpolated string closing quote ends an operand.
$s = "aa";
var_dump("a$s"<FOO);

// `static` is a complete operand (instanceof static), never markup.
class Base { public function check(object $o): bool { return $o instanceof static<FOO; } }
var_dump((new Base)->check(new Base));

// Bare `exit` is an expression operand; "<" after it is a comparison.
// (Only reached when the left side is falsy-compared, so guard it.)
function never_exit(): bool { return false && exit<FOO; }
var_dump(never_exit());

// The legacy `<>` operator is untouched in infix (operator) position.
var_dump($a[0]<>2);

// Plain variables, literals, and `)` end an operand too, with or without spaces.
$lhs = 1; $rhs = 2;
var_dump($lhs < $rhs);
var_dump($lhs<$rhs);
var_dump(3 < 4);
var_dump(($lhs) < $rhs);

// Other "<"-led operators are unchanged: spaceship, less-or-equal, shifts.
var_dump($lhs <=> $rhs);
var_dump($lhs <= $rhs);
var_dump(1 << 4);
var_dump(64 >> 2);

// A heredoc body containing "<" stays literal text, not markup.
echo <<<EOT
literal <not-markup> text
EOT, "\n";

// Error suppression with "@" still works before an expression.
$sup = []; var_dump(@$sup['missing']);

// Bare `yield` is a complete operand: `<` and `<>` after it stay comparisons
// ((yield) != 5, (yield) < FOO), with or without spaces. Yielding markup
// therefore needs parentheses: `yield (<div/>)`.
function gen_ne() { var_dump(yield <> 5); }
$g = gen_ne(); $g->current(); $g->send(7);
function gen_lt() { var_dump(yield < FOO); }
$g = gen_lt(); $g->current(); $g->send("aaa");
function gen_lt_nospace() { $r = yield<FOO; var_dump($r); }
$g = gen_lt_nospace(); $g->current(); $g->send("aaa");

// Inverse direction: in operand position (after `=`, `(`, `,`, `=>`, `return`,
// and parenthesized after `yield`) a "<tag" IS markup. Compile-time
// classification only - never executed, so this parses even in builds where
// the Markup\ runtime classes are unavailable.
function never_runs() {
$x = <div/>;
take(<p>hi</p>, <br/>);
$f = fn() => <span>a</span>;
yield (<li>item</li>);
return <em>done</em>;
}

// Dynamic tags: "<$tag" and "<{expr}" in operand position are markup
// ("$i--<$n" above is the operator-position inverse, still a comparison).
function never_runs_dynamic() {
$tag = 'div';
$x = <$tag class="a">hi</$tag>;
$y = <{$tag . 'x'}>hi</>;
take(<{pick()}/>);
return <$tag/>;
}
echo "parsed\n";
?>
--EXPECT--
bool(true)
bool(true)
int(4)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(false)
bool(false)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
int(-1)
bool(true)
int(16)
int(16)
literal <not-markup> text
NULL
bool(true)
bool(true)
bool(true)
parsed
5 changes: 5 additions & 0 deletions Zend/zend_globals.h
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@ struct _zend_php_scanner_globals {
/* initial string length after scanning to first variable */
int scanned_string_len;

/* Last significant token returned, used to decide whether a bare "<" in
* ST_IN_SCRIPTING is in operand position (begins native markup) or operator
* position (a comparison). See zend_language_scanner.l. */
int last_token;

/* hooks */
void (*on_event)(
zend_php_scanner_event event, int token, int line,
Expand Down
66 changes: 66 additions & 0 deletions Zend/zend_language_parser.y
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
#include "zend_constants.h"
#include "zend_language_scanner.h"
#include "zend_exceptions.h"
#include "zend_markup.h"

#define YYSIZE_T size_t
#define yytnamerr zend_yytnamerr
Expand Down Expand Up @@ -250,6 +251,20 @@ static YYSIZE_T zend_yytnamerr(char*, const char*);
/* Token used to force a parse error from the lexer */
%token T_ERROR

/* Native markup tokens. Declared last so they append to the token enum:
* inserting them mid-list would renumber every later token (T_INLINE_HTML
* onward), breaking code that hardcodes token integers. */
%token <ast> T_MARKUP_NAME "markup tag name"
%token <ast> T_MARKUP_TEXT "markup text"
%token <ast> T_MARKUP_ATTR_VALUE "markup attribute value"
%token <ast> T_MARKUP_COMMENT "markup comment"
%token T_MARKUP_OPEN "markup tag start"
%token T_MARKUP_CLOSE_OPEN "markup closing tag start"
%token T_MARKUP_TAG_END "markup tag end"
%token T_MARKUP_SELF_CLOSE "markup self-close"
%token T_MARKUP_INTERP_START "markup interpolation start"
%token <ast> T_MARKUP_DOCTYPE "markup doctype"

%type <ast> top_statement namespace_name name statement function_declaration_statement
%type <ast> class_declaration_statement trait_declaration_statement legacy_namespace_name
%type <ast> interface_declaration_statement interface_extends_list
Expand Down Expand Up @@ -283,6 +298,8 @@ static YYSIZE_T zend_yytnamerr(char*, const char*);
%type <ast> attributed_statement attributed_top_statement attributed_class_statement attributed_parameter
%type <ast> attribute_decl attribute attributes attribute_group namespace_declaration_name
%type <ast> match match_arm_list non_empty_match_arm_list match_arm match_arm_cond_list
%type <ast> markup_element markup_children markup_child
%type <ast> markup_attributes markup_attribute
%type <ast> enum_declaration_statement enum_backing_type enum_case enum_case_expr
%type <ast> function_name non_empty_member_modifiers
%type <ast> property_hook property_hook_list optional_property_hook_list hooked_property property_hook_body
Expand Down Expand Up @@ -1249,9 +1266,58 @@ new_non_dereferenceable:
{ $$ = zend_ast_create(ZEND_AST_NEW, $2, zend_ast_create_list(0, ZEND_AST_ARG_LIST)); }
;

markup_element:
T_MARKUP_OPEN T_MARKUP_NAME markup_attributes T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_MARKUP_NAME T_MARKUP_TAG_END
{ $$ = zend_ast_create_markup_checked($2, $3, $5, $7, false); if (!$$) { YYERROR; } }
| T_MARKUP_OPEN T_MARKUP_NAME markup_attributes T_MARKUP_SELF_CLOSE
{ $$ = zend_ast_create_markup_element($2, $3, NULL); if (!$$) { YYERROR; } }
| T_MARKUP_OPEN T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_MARKUP_TAG_END
{ $$ = zend_ast_create_markup_element(NULL, NULL, $3); if (!$$) { YYERROR; } }
| T_MARKUP_OPEN T_VARIABLE markup_attributes T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_VARIABLE T_MARKUP_TAG_END
{ $$ = zend_ast_create_markup_checked($2, $3, $5, $7, true); if (!$$) { YYERROR; } }
| T_MARKUP_OPEN T_VARIABLE markup_attributes T_MARKUP_SELF_CLOSE
{ $$ = zend_ast_create_markup_dynamic($2, $3, NULL); if (!$$) { YYERROR; } }
| T_MARKUP_OPEN T_MARKUP_INTERP_START expr '}' markup_attributes T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_MARKUP_TAG_END
{ $$ = zend_ast_create_markup_dynamic_expr($3, $5, $7); if (!$$) { YYERROR; } }
| T_MARKUP_OPEN T_MARKUP_INTERP_START expr '}' markup_attributes T_MARKUP_SELF_CLOSE
{ $$ = zend_ast_create_markup_dynamic_expr($3, $5, NULL); if (!$$) { YYERROR; } }
;

markup_attributes:
%empty { $$ = zend_ast_create_list(0, ZEND_AST_ARRAY); }
| markup_attributes markup_attribute { $$ = zend_ast_list_add($1, $2); }
;

markup_attribute:
T_MARKUP_NAME '=' T_MARKUP_ATTR_VALUE
{ $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $3, $1); }
| T_MARKUP_NAME '=' T_MARKUP_INTERP_START expr '}'
{ $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $4, $1); }
| T_MARKUP_NAME
{ zval t; ZVAL_TRUE(&t); $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_zval(&t), $1); }
| T_MARKUP_INTERP_START T_ELLIPSIS expr '}'
{ $$ = zend_ast_create(ZEND_AST_UNPACK, $3); }
;

markup_children:
%empty { $$ = zend_ast_create_list(0, ZEND_AST_ARRAY); }
| markup_children markup_child { $$ = zend_ast_list_add($1, $2); }
;

markup_child:
T_MARKUP_TEXT { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $1, NULL); }
| T_MARKUP_COMMENT { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_markup_raw($1), NULL); }
| T_MARKUP_DOCTYPE { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_markup_raw($1), NULL); }
| T_MARKUP_INTERP_START expr '}' { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $2, NULL); }
| T_MARKUP_INTERP_START '}' { zval n; ZVAL_NULL(&n); $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_zval(&n), NULL); }
| markup_element { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $1, NULL); }
;

expr:
variable
{ $$ = $1; }
| markup_element
{ $$ = $1; }
| T_LIST '(' array_pair_list ')' '=' expr
{ $3->attr = ZEND_ARRAY_SYNTAX_LIST; $$ = zend_ast_create(ZEND_AST_ASSIGN, $3, $6); }
| '[' array_pair_list ']' '=' expr
Expand Down
1 change: 1 addition & 0 deletions Zend/zend_language_scanner.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ typedef struct _zend_lex_state {
unsigned char *yy_marker;
unsigned char *yy_limit;
int yy_state;
int last_token; /* operand-vs-operator position for native markup */
zend_stack state_stack;
zend_ptr_stack heredoc_label_stack;
zend_stack nest_location_stack; /* for syntax error reporting */
Expand Down
Loading
Loading