From cb11172fd41494136e9eb6ffdae849f266b1cdde Mon Sep 17 00:00:00 2001 From: Chad Peppers Date: Sun, 28 Jun 2026 17:28:05 -0500 Subject: [PATCH 1/2] feat: accept declare(strict_types=1) and declare directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `declare` keyword was unrecognized, so `declare(strict_types=1);` — the first line of most modern PHP files — failed to parse ("Invalid assignment target"), which blocks compiling essentially any strict-typed codebase. Lex `declare` as a new token and parse `declare(name=value, ...)` in both the statement form (`declare(strict_types=1);`) and the block form (`declare(ticks=1) { ... }`). elephc compiles an always-strict subset, so the directives are parsed for syntactic validity and discarded: the statement form lowers to an empty `Synthetic` block (a no-op) and the block form to a `Synthetic` wrapper around its body. Reusing the existing `Synthetic` node means no analysis or lowering pass needs to change. Adds lexer, parser, codegen, and error tests, plus a docs section in docs/php/control-structures.md. (cherry picked from commit 88e78ca190361f59064d77d72657291aee74b4e1) (cherry picked from commit f256454ee95d0a6d13776332ac710a6c87e2a1b6) --- docs/php/control-structures.md | 25 ++++++ src/lexer/literals/identifiers.rs | 1 + src/lexer/token.rs | 1 + src/parser/stmt/mod.rs | 2 + src/parser/stmt/namespace_use.rs | 87 +++++++++++++++++++ tests/codegen/misc.rs | 16 ++++ tests/error_tests/misc/syntax_misc.rs | 9 ++ .../lexer_tests/keywords/language_keywords.rs | 20 +++++ tests/parser_tests/declarations.rs | 24 +++++ 9 files changed, 185 insertions(+) diff --git a/docs/php/control-structures.md b/docs/php/control-structures.md index 1c920b420f..9c7576810e 100644 --- a/docs/php/control-structures.md +++ b/docs/php/control-structures.md @@ -5,6 +5,31 @@ sidebar: order: 3 --- +## declare + +`declare(strict_types=1);` is accepted at the top of a file. elephc compiles a +statically-typed subset and is **always strict**, so the directive is parsed and +treated as a no-op rather than toggling a runtime mode. The `ticks` and `encoding` +directives are likewise accepted and ignored. + +```php + Result Ok(Token::Namespace), "const" => Ok(Token::Const), "global" => Ok(Token::Global), + "declare" => Ok(Token::Declare), "static" => Ok(Token::Static), "self" => Ok(Token::Self_), "trait" => Ok(Token::Trait), diff --git a/src/lexer/token.rs b/src/lexer/token.rs index a611c5ce79..23420bcdb0 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -86,6 +86,7 @@ pub enum Token { Namespace, // namespace Const, // const Global, // global + Declare, // declare (strict_types/ticks/encoding directive) Static, // static Self_, // self Trait, // trait diff --git a/src/parser/stmt/mod.rs b/src/parser/stmt/mod.rs index cdb61be423..ebf606f07e 100644 --- a/src/parser/stmt/mod.rs +++ b/src/parser/stmt/mod.rs @@ -114,6 +114,7 @@ fn parse_stmt_dispatch( Token::Function => params::parse_function_decl(tokens, pos, span), Token::Namespace => namespace_use::parse_namespace_stmt(tokens, pos, span), Token::Use => namespace_use::parse_use_stmt(tokens, pos, span), + Token::Declare => namespace_use::parse_declare(tokens, pos, span), Token::Return => simple::parse_return(tokens, pos, span), Token::Throw => simple::parse_throw(tokens, pos, span), Token::Yield => { @@ -334,6 +335,7 @@ pub(crate) fn recover_to_statement_boundary(tokens: &[(Token, Span)], pos: &mut | Token::Function | Token::Namespace | Token::Use + | Token::Declare | Token::Return | Token::Throw | Token::Include diff --git a/src/parser/stmt/namespace_use.rs b/src/parser/stmt/namespace_use.rs index e9ede8ebfa..cd6aff4797 100644 --- a/src/parser/stmt/namespace_use.rs +++ b/src/parser/stmt/namespace_use.rs @@ -12,6 +12,7 @@ use crate::errors::CompileError; use crate::lexer::Token; use crate::names::{Name, NameKind}; use crate::parser::ast::{Stmt, StmtKind, UseItem, UseKind}; +use crate::parser::expr::parse_expr; use crate::span::Span; use super::{expect_semicolon, expect_token, parse_name, parse_stmt, recover_to_statement_boundary}; @@ -76,6 +77,92 @@ pub(super) fn parse_namespace_stmt( Ok(Stmt::new(StmtKind::NamespaceBlock { name, body }, span)) } +/// Parses a PHP `declare(directive=value, ...)` statement or block. +/// +/// elephc is strict by construction, so the directives (`strict_types`, `ticks`, +/// `encoding`) are parsed for syntactic validity and then discarded. The statement +/// form `declare(strict_types=1);` lowers to an empty `Synthetic` block (a no-op); +/// the block form `declare(ticks=1) { ... }` lowers to a `Synthetic` wrapper around +/// its body, which executes in the enclosing scope. +pub(super) fn parse_declare( + tokens: &[(Token, Span)], + pos: &mut usize, + span: Span, +) -> Result { + *pos += 1; // consume declare + + expect_token(tokens, pos, &Token::LParen, "Expected '(' after 'declare'")?; + + // Parse one or more `directive = value` pairs. Directive names are accepted for + // syntactic validity; their values do not affect an always-strict AOT compiler. + loop { + match tokens.get(*pos).map(|(t, _)| t) { + Some(Token::Identifier(_)) => *pos += 1, + _ => { + return Err(CompileError::new( + span, + "Expected a directive name in 'declare(...)'", + )) + } + } + expect_token( + tokens, + pos, + &Token::Assign, + "Expected '=' after declare directive name", + )?; + let _ = parse_expr(tokens, pos)?; // discard the directive value + + if *pos < tokens.len() && tokens[*pos].0 == Token::Comma { + *pos += 1; + continue; + } + break; + } + + expect_token( + tokens, + pos, + &Token::RParen, + "Expected ')' after declare directives", + )?; + + // Statement form `declare(...);` is a no-op: lower to an empty Synthetic block. + if *pos < tokens.len() && tokens[*pos].0 == Token::Semicolon { + *pos += 1; + return Ok(Stmt::new(StmtKind::Synthetic(Vec::new()), span)); + } + + // Block form `declare(...) { ... }`: wrap the body in a scope-preserving Synthetic. + expect_token( + tokens, + pos, + &Token::LBrace, + "Expected ';' or '{' after declare(...)", + )?; + let mut body = Vec::new(); + let mut errors = Vec::new(); + while *pos < tokens.len() && !matches!(tokens[*pos].0, Token::RBrace | Token::Eof) { + match parse_stmt(tokens, pos) { + Ok(stmt) => body.push(stmt), + Err(error) => { + errors.extend(error.flatten()); + recover_to_statement_boundary(tokens, pos); + } + } + } + expect_token( + tokens, + pos, + &Token::RBrace, + "Expected '}' after declare block", + )?; + if !errors.is_empty() { + return Err(CompileError::from_many(errors)); + } + Ok(Stmt::new(StmtKind::Synthetic(body), span)) +} + /// Parses a `use` import statement. /// /// Handles `use`, `use function`, and `use const` declarations, including: diff --git a/tests/codegen/misc.rs b/tests/codegen/misc.rs index 18dd3937f8..d15a9e97df 100644 --- a/tests/codegen/misc.rs +++ b/tests/codegen/misc.rs @@ -26,6 +26,22 @@ fn test_top_level_return_value_halts_and_is_discarded() { assert_eq!(out, "a"); } +/// Verifies `declare(strict_types=1);` is accepted and compiles as a no-op, +/// so the rest of the program runs normally (elephc is always strict). +#[test] +fn test_declare_strict_types_is_accepted_and_noop() { + let out = compile_and_run(" assert!(body.is_empty()), + other => panic!("Expected empty Synthetic, got {:?}", other), + } +} + +/// Verifies `declare(ticks=1) { ... }` (block form) parses to a `Synthetic` +/// wrapper around its body statements. +#[test] +fn test_declare_block_parses_to_synthetic_body() { + let stmts = parse_source(" assert_eq!(body.len(), 1), + other => panic!("Expected Synthetic body, got {:?}", other), + } +} + /// Verifies that ` Date: Fri, 10 Jul 2026 19:03:48 +0200 Subject: [PATCH 2/2] fix: align declare syntax with PHP --- docs/php/control-structures.md | 16 +- examples/declare-directives/.gitignore | 3 + examples/declare-directives/main.php | 13 ++ src/lexer/literals/identifiers.rs | 1 + src/lexer/token.rs | 1 + src/parser/keyword_name.rs | 2 + src/parser/stmt/declare.rs | 189 ++++++++++++++++++ src/parser/stmt/mod.rs | 6 +- src/parser/stmt/namespace_use.rs | 87 -------- tests/codegen/misc.rs | 23 +++ tests/error_tests/misc/syntax_misc.rs | 63 ++++++ .../lexer_tests/keywords/language_keywords.rs | 9 + tests/parser_tests/declarations.rs | 37 ++++ 13 files changed, 361 insertions(+), 89 deletions(-) create mode 100644 examples/declare-directives/.gitignore create mode 100644 examples/declare-directives/main.php create mode 100644 src/parser/stmt/declare.rs diff --git a/docs/php/control-structures.md b/docs/php/control-structures.md index 9c7576810e..19815ec934 100644 --- a/docs/php/control-structures.md +++ b/docs/php/control-structures.md @@ -10,7 +10,9 @@ sidebar: `declare(strict_types=1);` is accepted at the top of a file. elephc compiles a statically-typed subset and is **always strict**, so the directive is parsed and treated as a no-op rather than toggling a runtime mode. The `ticks` and `encoding` -directives are likewise accepted and ignored. +directives are likewise accepted and ignored. Directive values must be PHP +literals; `strict_types` must be the first statement, use the statement form, and +have the integer value `0` or `1`. ```php Result Ok(Token::Const), "global" => Ok(Token::Global), "declare" => Ok(Token::Declare), + "enddeclare" => Ok(Token::EndDeclare), "static" => Ok(Token::Static), "self" => Ok(Token::Self_), "trait" => Ok(Token::Trait), diff --git a/src/lexer/token.rs b/src/lexer/token.rs index 23420bcdb0..6b651724b9 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -87,6 +87,7 @@ pub enum Token { Const, // const Global, // global Declare, // declare (strict_types/ticks/encoding directive) + EndDeclare, // enddeclare (alternative declare block terminator) Static, // static Self_, // self Trait, // trait diff --git a/src/parser/keyword_name.rs b/src/parser/keyword_name.rs index 0d871c9760..5d95d0351f 100644 --- a/src/parser/keyword_name.rs +++ b/src/parser/keyword_name.rs @@ -64,6 +64,8 @@ pub(crate) fn bareword_name_from_token(token: &Token) -> Option { Token::Namespace => Some("namespace".to_string()), Token::Const => Some("const".to_string()), Token::Global => Some("global".to_string()), + Token::Declare => Some("declare".to_string()), + Token::EndDeclare => Some("enddeclare".to_string()), Token::Static => Some("static".to_string()), Token::Self_ => Some("self".to_string()), Token::Trait => Some("trait".to_string()), diff --git a/src/parser/stmt/declare.rs b/src/parser/stmt/declare.rs new file mode 100644 index 0000000000..c156787377 --- /dev/null +++ b/src/parser/stmt/declare.rs @@ -0,0 +1,189 @@ +//! Purpose: +//! Parses PHP `declare` directives and their statement, braced, or alternative-syntax bodies. +//! Validates PHP's literal-value and `strict_types` placement/form restrictions. +//! +//! Called from: +//! - `crate::parser::stmt::parse_stmt()` when the current token is `declare`. +//! +//! Key details: +//! - Directives are compile-time syntax only because elephc always uses strict typing. +//! - Bodies lower through `Synthetic` so they execute in the enclosing scope. + +use crate::errors::CompileError; +use crate::lexer::Token; +use crate::parser::ast::{Stmt, StmtKind}; +use crate::span::Span; + +use super::{ + expect_semicolon, expect_token, parse_block, parse_stmt, recover_to_statement_boundary, +}; + +/// Parses `declare(directive=literal, ...)` and lowers its effective body to `Synthetic`. +pub(super) fn parse_declare( + tokens: &[(Token, Span)], + pos: &mut usize, + span: Span, +) -> Result { + let declare_pos = *pos; + *pos += 1; + + expect_token(tokens, pos, &Token::LParen, "Expected '(' after 'declare'")?; + let has_strict_types = parse_directives(tokens, pos, span)?; + expect_token( + tokens, + pos, + &Token::RParen, + "Expected ')' after declare directives", + )?; + + if has_strict_types && declare_pos != 1 { + return Err(CompileError::new( + span, + "strict_types declaration must be the very first statement in the script", + )); + } + + if matches!( + tokens.get(*pos).map(|(token, _)| token), + Some(Token::Semicolon) + ) { + *pos += 1; + return Ok(Stmt::new(StmtKind::Synthetic(Vec::new()), span)); + } + + if has_strict_types { + return Err(CompileError::new( + span, + "strict_types declaration must not use block mode", + )); + } + + let body = match tokens.get(*pos).map(|(token, _)| token) { + Some(Token::LBrace) => parse_block(tokens, pos)?, + Some(Token::Colon) => parse_alternative_body(tokens, pos)?, + Some(Token::Eof) | None => { + return Err(CompileError::new( + span, + "Expected a statement after declare(...)", + )); + } + _ => vec![parse_stmt(tokens, pos)?], + }; + + Ok(Stmt::new(StmtKind::Synthetic(body), span)) +} + +/// Parses one or more directive/literal pairs and reports whether `strict_types` occurred. +fn parse_directives( + tokens: &[(Token, Span)], + pos: &mut usize, + declare_span: Span, +) -> Result { + let mut has_strict_types = false; + + loop { + let (name, name_span) = match tokens.get(*pos) { + Some((Token::Identifier(name), span)) => (name.clone(), *span), + _ => { + return Err(CompileError::new( + declare_span, + "Expected a directive name in 'declare(...)'", + )); + } + }; + *pos += 1; + + expect_token( + tokens, + pos, + &Token::Assign, + "Expected '=' after declare directive name", + )?; + let integer_value = parse_literal_value(tokens, pos, &name, name_span)?; + + if !matches!( + tokens.get(*pos).map(|(token, _)| token), + Some(Token::Comma | Token::RParen) + ) { + return Err(CompileError::new( + name_span, + &format!("declare({}) value must be a literal", name), + )); + } + + if name.eq_ignore_ascii_case("strict_types") { + has_strict_types = true; + if !matches!(integer_value, Some(0 | 1)) { + return Err(CompileError::new( + name_span, + "strict_types declaration must have 0 or 1 as its value", + )); + } + } + + if !matches!(tokens.get(*pos).map(|(token, _)| token), Some(Token::Comma)) { + break; + } + *pos += 1; + } + + Ok(has_strict_types) +} + +/// Consumes a PHP declare literal and returns its integer value when it is an integer. +fn parse_literal_value( + tokens: &[(Token, Span)], + pos: &mut usize, + directive: &str, + directive_span: Span, +) -> Result, CompileError> { + match tokens.get(*pos).map(|(token, _)| token) { + Some(Token::IntLiteral(value)) => { + let value = *value; + *pos += 1; + Ok(Some(value)) + } + Some(Token::FloatLiteral(_) | Token::StringLiteral(_)) => { + *pos += 1; + Ok(None) + } + _ => Err(CompileError::new( + directive_span, + &format!("declare({}) value must be a literal", directive), + )), + } +} + +/// Parses `: ... enddeclare;`, collecting nested statement errors before closing the block. +fn parse_alternative_body( + tokens: &[(Token, Span)], + pos: &mut usize, +) -> Result, CompileError> { + *pos += 1; + let mut body = Vec::new(); + let mut errors = Vec::new(); + + while *pos < tokens.len() && !matches!(tokens[*pos].0, Token::EndDeclare | Token::Eof) { + match parse_stmt(tokens, pos) { + Ok(stmt) => body.push(stmt), + Err(error) => { + errors.extend(error.flatten()); + recover_to_statement_boundary(tokens, pos); + } + } + } + + expect_token( + tokens, + pos, + &Token::EndDeclare, + "Expected 'enddeclare' after declare block", + )?; + expect_semicolon(tokens, pos)?; + + if errors.is_empty() { + Ok(body) + } else { + Err(CompileError::from_many(errors)) + } +} diff --git a/src/parser/stmt/mod.rs b/src/parser/stmt/mod.rs index ebf606f07e..6befd9cda1 100644 --- a/src/parser/stmt/mod.rs +++ b/src/parser/stmt/mod.rs @@ -9,6 +9,7 @@ //! - Recovery stops at PHP statement boundaries so follow-up diagnostics remain useful. mod assign; +mod declare; mod ffi; mod namespace_use; mod oop; @@ -114,7 +115,7 @@ fn parse_stmt_dispatch( Token::Function => params::parse_function_decl(tokens, pos, span), Token::Namespace => namespace_use::parse_namespace_stmt(tokens, pos, span), Token::Use => namespace_use::parse_use_stmt(tokens, pos, span), - Token::Declare => namespace_use::parse_declare(tokens, pos, span), + Token::Declare => declare::parse_declare(tokens, pos, span), Token::Return => simple::parse_return(tokens, pos, span), Token::Throw => simple::parse_throw(tokens, pos, span), Token::Yield => { @@ -315,6 +316,9 @@ pub(crate) fn recover_to_statement_boundary(tokens: &[(Token, Span)], pos: &mut Token::RBrace if paren_depth == 0 && bracket_depth == 0 => { break; } + Token::EndDeclare if paren_depth == 0 && bracket_depth == 0 => { + break; + } Token::Eof if paren_depth == 0 && bracket_depth == 0 => { break; } diff --git a/src/parser/stmt/namespace_use.rs b/src/parser/stmt/namespace_use.rs index cd6aff4797..e9ede8ebfa 100644 --- a/src/parser/stmt/namespace_use.rs +++ b/src/parser/stmt/namespace_use.rs @@ -12,7 +12,6 @@ use crate::errors::CompileError; use crate::lexer::Token; use crate::names::{Name, NameKind}; use crate::parser::ast::{Stmt, StmtKind, UseItem, UseKind}; -use crate::parser::expr::parse_expr; use crate::span::Span; use super::{expect_semicolon, expect_token, parse_name, parse_stmt, recover_to_statement_boundary}; @@ -77,92 +76,6 @@ pub(super) fn parse_namespace_stmt( Ok(Stmt::new(StmtKind::NamespaceBlock { name, body }, span)) } -/// Parses a PHP `declare(directive=value, ...)` statement or block. -/// -/// elephc is strict by construction, so the directives (`strict_types`, `ticks`, -/// `encoding`) are parsed for syntactic validity and then discarded. The statement -/// form `declare(strict_types=1);` lowers to an empty `Synthetic` block (a no-op); -/// the block form `declare(ticks=1) { ... }` lowers to a `Synthetic` wrapper around -/// its body, which executes in the enclosing scope. -pub(super) fn parse_declare( - tokens: &[(Token, Span)], - pos: &mut usize, - span: Span, -) -> Result { - *pos += 1; // consume declare - - expect_token(tokens, pos, &Token::LParen, "Expected '(' after 'declare'")?; - - // Parse one or more `directive = value` pairs. Directive names are accepted for - // syntactic validity; their values do not affect an always-strict AOT compiler. - loop { - match tokens.get(*pos).map(|(t, _)| t) { - Some(Token::Identifier(_)) => *pos += 1, - _ => { - return Err(CompileError::new( - span, - "Expected a directive name in 'declare(...)'", - )) - } - } - expect_token( - tokens, - pos, - &Token::Assign, - "Expected '=' after declare directive name", - )?; - let _ = parse_expr(tokens, pos)?; // discard the directive value - - if *pos < tokens.len() && tokens[*pos].0 == Token::Comma { - *pos += 1; - continue; - } - break; - } - - expect_token( - tokens, - pos, - &Token::RParen, - "Expected ')' after declare directives", - )?; - - // Statement form `declare(...);` is a no-op: lower to an empty Synthetic block. - if *pos < tokens.len() && tokens[*pos].0 == Token::Semicolon { - *pos += 1; - return Ok(Stmt::new(StmtKind::Synthetic(Vec::new()), span)); - } - - // Block form `declare(...) { ... }`: wrap the body in a scope-preserving Synthetic. - expect_token( - tokens, - pos, - &Token::LBrace, - "Expected ';' or '{' after declare(...)", - )?; - let mut body = Vec::new(); - let mut errors = Vec::new(); - while *pos < tokens.len() && !matches!(tokens[*pos].0, Token::RBrace | Token::Eof) { - match parse_stmt(tokens, pos) { - Ok(stmt) => body.push(stmt), - Err(error) => { - errors.extend(error.flatten()); - recover_to_statement_boundary(tokens, pos); - } - } - } - expect_token( - tokens, - pos, - &Token::RBrace, - "Expected '}' after declare block", - )?; - if !errors.is_empty() { - return Err(CompileError::from_many(errors)); - } - Ok(Stmt::new(StmtKind::Synthetic(body), span)) -} - /// Parses a `use` import statement. /// /// Handles `use`, `use function`, and `use const` declarations, including: diff --git a/tests/codegen/misc.rs b/tests/codegen/misc.rs index d15a9e97df..4f751134c8 100644 --- a/tests/codegen/misc.rs +++ b/tests/codegen/misc.rs @@ -42,6 +42,29 @@ fn test_declare_block_runs_body() { assert_eq!(out, "ok"); } +/// Verifies PHP's alternative declare syntax compiles and executes its body. +#[test] +fn test_declare_alternative_syntax_runs_body() { + let out = compile_and_run(" assert_eq!(body.len(), 1), + other => panic!("Expected Synthetic body, got {:?}", other), + } +} + +/// Verifies PHP's single-statement `declare(...) statement` form wraps that statement. +#[test] +fn test_declare_single_statement_parses_to_synthetic_body() { + let stmts = parse_source(" assert_eq!(body.len(), 1), + other => panic!("Expected Synthetic body, got {:?}", other), + } +} + +/// Verifies declare values accept PHP literal float and string forms. +#[test] +fn test_declare_accepts_php_literal_value_kinds() { + let stmts = parse_source("