From b0056a7d7470c68d83077ea528e1f02d1210861a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 10:25:40 +0000 Subject: [PATCH 1/7] feat: add Termwind-facing LIBXML_* constants Register the five loadHTML flags Termwind ORs (NOXMLDECL, HTML_NODEFDTD, NOERROR, NOBLANKS, COMPACT) with php-src integer values so namespaced and eval code can fold the same bitmasks. Co-authored-by: Vincenzo Petrucci --- .../src/interpreter/constant_eval.rs | 3 +- .../src/interpreter/constants.rs | 18 +++++ src/codegen_support/prescan.rs | 7 ++ src/name_resolver/names.rs | 6 +- src/types/checker/driver/init.rs | 4 ++ src/types/libxml_constants.rs | 66 +++++++++++++++++++ src/types/mod.rs | 2 + 7 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 src/types/libxml_constants.rs diff --git a/crates/elephc-magician/src/interpreter/constant_eval.rs b/crates/elephc-magician/src/interpreter/constant_eval.rs index b8ac5c51fc..484ebb3cff 100644 --- a/crates/elephc-magician/src/interpreter/constant_eval.rs +++ b/crates/elephc-magician/src/interpreter/constant_eval.rs @@ -204,8 +204,9 @@ pub(in crate::interpreter) fn eval_predefined_constant_value( // arms. Table-driven, not gated behind the `curl` Cargo feature: see // `super::curl_constants`'s header for why a bare numeric constant carries no // ABI-linkage cost. - other => super::curl_constants::EVAL_CURL_INT_CONSTANTS + other => EVAL_LIBXML_INT_CONSTANTS .iter() + .chain(super::curl_constants::EVAL_CURL_INT_CONSTANTS.iter()) .find(|(name, _)| *name == other) .map(|(_, value)| EvalPredefinedConstant::Int(*value)), } diff --git a/crates/elephc-magician/src/interpreter/constants.rs b/crates/elephc-magician/src/interpreter/constants.rs index 5ffb280357..187c4efd21 100644 --- a/crates/elephc-magician/src/interpreter/constants.rs +++ b/crates/elephc-magician/src/interpreter/constants.rs @@ -318,6 +318,24 @@ pub(super) const EVAL_JSON_PRESERVE_ZERO_FRACTION: i64 = 1024; pub(super) const EVAL_JSON_INVALID_UTF8_IGNORE: i64 = 1_048_576; pub(super) const EVAL_JSON_INVALID_UTF8_SUBSTITUTE: i64 = 2_097_152; pub(super) const EVAL_JSON_THROW_ON_ERROR: i64 = 4_194_304; +/// `XML_SAVE_NO_DECL` — omit the XML declaration from `saveXML()`. +pub(super) const EVAL_LIBXML_NOXMLDECL: i64 = 2; +/// `HTML_PARSE_NODEFDTD` — do not add a default doctype. +pub(super) const EVAL_LIBXML_HTML_NODEFDTD: i64 = 4; +/// `XML_PARSE_NOERROR` — suppress parser error reports. +pub(super) const EVAL_LIBXML_NOERROR: i64 = 32; +/// `XML_PARSE_NOBLANKS` — drop whitespace-only text nodes. +pub(super) const EVAL_LIBXML_NOBLANKS: i64 = 256; +/// `XML_PARSE_COMPACT` — compact small text nodes. +pub(super) const EVAL_LIBXML_COMPACT: i64 = 65536; +/// Termwind-facing `LIBXML_*` flags, shared with the AOT `LIBXML_INT_CONSTANTS` table. +pub(super) const EVAL_LIBXML_INT_CONSTANTS: &[(&str, i64)] = &[ + ("LIBXML_NOXMLDECL", EVAL_LIBXML_NOXMLDECL), + ("LIBXML_HTML_NODEFDTD", EVAL_LIBXML_HTML_NODEFDTD), + ("LIBXML_NOERROR", EVAL_LIBXML_NOERROR), + ("LIBXML_NOBLANKS", EVAL_LIBXML_NOBLANKS), + ("LIBXML_COMPACT", EVAL_LIBXML_COMPACT), +]; pub(super) const EVAL_JSON_INF_OR_NAN_MESSAGE: &str = "Inf and NaN cannot be JSON encoded"; pub(super) const EVAL_JSON_UTF8_MESSAGE: &str = "Malformed UTF-8 characters, possibly incorrectly encoded"; diff --git a/src/codegen_support/prescan.rs b/src/codegen_support/prescan.rs index 703d8285de..83a7a8c7c1 100644 --- a/src/codegen_support/prescan.rs +++ b/src/codegen_support/prescan.rs @@ -18,6 +18,7 @@ use crate::types::date_constants::DATE_INT_CONSTANTS; use crate::types::ent_constants::ENT_INT_CONSTANTS; use crate::types::error_constants::ERROR_LEVEL_CONSTANTS; use crate::types::json_constants::JSON_INT_CONSTANTS; +use crate::types::libxml_constants::LIBXML_INT_CONSTANTS; use crate::types::math_constants::MATH_INT_CONSTANTS; use crate::types::openssl_constants::OPENSSL_INT_CONSTANTS; use crate::types::preg_constants::PREG_INT_CONSTANTS; @@ -230,6 +231,12 @@ pub(crate) fn collect_constants( (ExprKind::IntLiteral(*value), PhpType::Int), ); } + for (name, value) in LIBXML_INT_CONSTANTS { + constants.insert( + (*name).to_string(), + (ExprKind::IntLiteral(*value), PhpType::Int), + ); + } for (name, value) in MATH_INT_CONSTANTS { constants.insert( (*name).to_string(), diff --git a/src/name_resolver/names.rs b/src/name_resolver/names.rs index 88246ece39..e9aa014016 100644 --- a/src/name_resolver/names.rs +++ b/src/name_resolver/names.rs @@ -410,10 +410,11 @@ fn is_builtin_global_constant(name: &str) -> bool { return true; } // Shared source-of-truth slices for JSON, stream/socket, session, array, math, iconv, - // and curl + // curl, and Termwind-facing libxml // constants. CURL_INT_CONSTANTS is always in this chain (like JSON_INT_CONSTANTS) so a // bare `CURLOPT_URL` mention resolves to the global constant even inside a namespace, - // with or without the curl prelude/bridge being linked. + // with or without the curl prelude/bridge being linked. LIBXML_* follows the same + // rule so `namespace Termwind { LIBXML_NOERROR }` falls back to the global flag. crate::types::json_constants::JSON_INT_CONSTANTS .iter() .chain(crate::types::openssl_constants::OPENSSL_INT_CONSTANTS.iter()) @@ -424,5 +425,6 @@ fn is_builtin_global_constant(name: &str) -> bool { .chain(crate::types::math_constants::MATH_INT_CONSTANTS.iter()) .chain(crate::types::iconv_constants::ICONV_INT_CONSTANTS.iter()) .chain(crate::types::curl_constants::CURL_INT_CONSTANTS.iter()) + .chain(crate::types::libxml_constants::LIBXML_INT_CONSTANTS.iter()) .any(|(constant_name, _)| *constant_name == name) } diff --git a/src/types/checker/driver/init.rs b/src/types/checker/driver/init.rs index b980fe4ebc..7761d5ce8b 100644 --- a/src/types/checker/driver/init.rs +++ b/src/types/checker/driver/init.rs @@ -18,6 +18,7 @@ use crate::types::ent_constants::ENT_INT_CONSTANTS; use crate::types::error_constants::ERROR_LEVEL_CONSTANTS; use crate::types::iconv_constants::ICONV_INT_CONSTANTS; use crate::types::json_constants::JSON_INT_CONSTANTS; +use crate::types::libxml_constants::LIBXML_INT_CONSTANTS; use crate::types::math_constants::MATH_INT_CONSTANTS; use crate::types::openssl_constants::OPENSSL_INT_CONSTANTS; use crate::types::session_constants::SESSION_INT_CONSTANTS; @@ -102,6 +103,9 @@ impl Checker { for (name, _value) in JSON_INT_CONSTANTS { constants.insert((*name).to_string(), PhpType::Int); } + for (name, _value) in LIBXML_INT_CONSTANTS { + constants.insert((*name).to_string(), PhpType::Int); + } for (name, _value) in MATH_INT_CONSTANTS { constants.insert((*name).to_string(), PhpType::Int); } diff --git a/src/types/libxml_constants.rs b/src/types/libxml_constants.rs new file mode 100644 index 0000000000..abfc32e2e0 --- /dev/null +++ b/src/types/libxml_constants.rs @@ -0,0 +1,66 @@ +//! Purpose: +//! Defines the `LIBXML_*` integer constants Termwind and other HTML/XML callers +//! pass to `DOMDocument::loadHTML()`. +//! +//! Called from: +//! - `crate::types::checker::driver::init` when registering predefined constants. +//! - `crate::codegen_support::prescan` when materializing constant literal values. +//! - `crate::name_resolver::names` so an unqualified `LIBXML_*` name inside a +//! namespace falls back to the global constant the way PHP does. +//! +//! Key details: +//! - Values match php-src `ext/libxml` / libxml2 exactly so flag arithmetic +//! (`LIBXML_NOERROR | LIBXML_NOBLANKS | …`) folds identically to PHP. +//! - This table is the Termwind-facing subset, not the full libxml constant +//! surface. Draft PR #654 (`feat/php-dom-compliance`) owns complete PHP 8.5 +//! libxml/DOM parity; keep these integers stable so that work can absorb them. + +/// Tuple of `(name, value)` pairs for the Termwind-facing `LIBXML_*` flags. +/// +/// Example entries: `("LIBXML_NOERROR", 32)`, `("LIBXML_NOXMLDECL", 2)`. +pub(crate) const LIBXML_INT_CONSTANTS: &[(&str, i64)] = &[ + // `XML_SAVE_NO_DECL` — omit the XML declaration from `saveXML()`. Also + // accepted as a `loadHTML()` flag (Termwind ORs it in); parse ignores it. + ("LIBXML_NOXMLDECL", 2), + // `HTML_PARSE_NODEFDTD` — do not add a default doctype. + ("LIBXML_HTML_NODEFDTD", 4), + // `XML_PARSE_NOERROR` — suppress parser error reports. + ("LIBXML_NOERROR", 32), + // `XML_PARSE_NOBLANKS` — drop whitespace-only text nodes. + ("LIBXML_NOBLANKS", 256), + // `XML_PARSE_COMPACT` — compact small text nodes (ignored by the subset parser). + ("LIBXML_COMPACT", 65536), +]; + +#[cfg(test)] +mod tests { + use super::LIBXML_INT_CONSTANTS; + + /// Looks up one `LIBXML_*` constant by name. + fn value(name: &str) -> i64 { + LIBXML_INT_CONSTANTS + .iter() + .find(|(n, _)| *n == name) + .unwrap_or_else(|| panic!("{name} defined")) + .1 + } + + /// Verifies Termwind's `loadHTML()` flag integers match php-src / libxml2. + #[test] + fn test_termwind_libxml_flag_values() { + assert_eq!(value("LIBXML_NOXMLDECL"), 2); + assert_eq!(value("LIBXML_HTML_NODEFDTD"), 4); + assert_eq!(value("LIBXML_NOERROR"), 32); + assert_eq!(value("LIBXML_NOBLANKS"), 256); + assert_eq!(value("LIBXML_COMPACT"), 65536); + } + + /// Verifies that no `LIBXML_*` constant name is declared twice. + #[test] + fn test_libxml_constants_have_unique_names() { + let mut names: Vec<&str> = LIBXML_INT_CONSTANTS.iter().map(|(name, _)| *name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), LIBXML_INT_CONSTANTS.len()); + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index 05771ab129..70d5a1a854 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -29,6 +29,8 @@ pub(crate) mod fibers; pub(crate) mod date_constants; /// `ENT_*` HTML-escaping flag constants shared by checker and codegen. pub(crate) mod ent_constants; +/// Termwind-facing `LIBXML_*` parse/save flag constants shared by checker and codegen. +pub(crate) mod libxml_constants; /// PHP `E_*` error-level integer constants (`error_reporting` bitmask levels). pub(crate) mod error_constants; /// C FFI type mapping utilities. From b915b03e4e796685e6b29d136652e9a4964b57c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 10:25:43 +0000 Subject: [PATCH 2/7] feat: inject a Termwind-scoped DOM HTML prelude Add a pay-for-use HTML fragment walker (DOMDocument::loadHTML and the node types Termwind walks) instead of a second native libxml stack. Draft PR #654 remains the full PHP 8.5 DOM path. Co-authored-by: Vincenzo Petrucci --- src/dom_html_prelude.rs | 109 ++++++ src/dom_html_prelude/detect.rs | 615 ++++++++++++++++++++++++++++++ src/dom_html_prelude/surface.rs | 552 +++++++++++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 1 + src/pipeline.rs | 8 + tests/codegen/support/compiler.rs | 1 + tests/error_tests.rs | 2 + 8 files changed, 1290 insertions(+) create mode 100644 src/dom_html_prelude.rs create mode 100644 src/dom_html_prelude/detect.rs create mode 100644 src/dom_html_prelude/surface.rs diff --git a/src/dom_html_prelude.rs b/src/dom_html_prelude.rs new file mode 100644 index 0000000000..a7c1a36045 --- /dev/null +++ b/src/dom_html_prelude.rs @@ -0,0 +1,109 @@ +//! Purpose: +//! Termwind-facing DOM HTML subset: `DOMDocument::loadHTML()` plus the node +//! types and properties `HtmlRenderer` / `ValueObjects\Node` walk +//! (`getElementsByTagName`, `childNodes`, `nodeName`, `nodeValue`, +//! `getAttribute`, sibling pointers, `saveXML`). +//! +//! Called from: +//! - `crate::pipeline::compile()` and the codegen test harness via +//! `inject_if_used`, after include resolution and before name resolution. +//! +//! Key details: +//! - Choice (B) versus draft PR #654: that PR is a 178k-line PHP 8.5 +//! libxml2+Lexbor bridge (`crates/elephc-dom`) still in progress. This +//! prelude is a pay-for-use HTML fragment walker that does not add a +//! second native DOM engine. Same PHP class names and `LIBXML_*` +//! integers so #654 can replace this surface when it lands. +//! - Injected only when the program names a DOM class. No `--with-dom` +//! flag and no `elephc-dom` crate. +//! - Delivered as parsed PHP (like mysqli), not a native builtin, so every +//! supported target gets the same walk with no new assembly. + +mod detect; +mod surface; + +use std::sync::OnceLock; + +use crate::parser::ast::Program; + +/// Parsed prelude cache. The fragment is declaration-only, so one parse is +/// reused for every injecting compile. +static PARSED_PRELUDE: OnceLock = OnceLock::new(); + +/// Tokenizes and parses the DOM HTML prelude exactly once. +fn parsed_prelude() -> Program { + PARSED_PRELUDE + .get_or_init(|| { + let source = format!(" Program { + if !force && !detect::program_uses_dom_html(&program) { + return program; + } + let mut combined = parsed_prelude(); + inventory.record_program("dom-html", &combined); + combined.extend(program); + combined +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::ast::StmtKind; + + /// The prelude must declare the Termwind node types and no others. + #[test] + fn declares_termwind_dom_classes() { + let declared: Vec = parsed_prelude() + .iter() + .filter_map(|stmt| match &stmt.kind { + StmtKind::ClassDecl { name, .. } => Some(name.clone()), + _ => None, + }) + .collect(); + assert_eq!( + declared, + vec![ + "DOMNode", + "DOMNodeList", + "DOMDocument", + "DOMElement", + "DOMCharacterData", + "DOMText", + "DOMComment", + ] + ); + } + + /// `loadHTML` keeps the two-parameter PHP signature Termwind calls. + #[test] + fn load_html_takes_source_and_options() { + let load = parsed_prelude() + .into_iter() + .find(|stmt| matches!(&stmt.kind, StmtKind::ClassDecl { name, .. } if name == "DOMDocument")) + .expect("DOMDocument must be declared"); + let StmtKind::ClassDecl { methods, .. } = &load.kind else { + unreachable!("filtered above"); + }; + let method = methods + .iter() + .find(|method| method.name == "loadHTML") + .expect("loadHTML must be declared"); + assert_eq!(method.params.len(), 2); + assert_eq!(method.params[0].0, "source"); + assert_eq!(method.params[1].0, "options"); + } +} diff --git a/src/dom_html_prelude/detect.rs b/src/dom_html_prelude/detect.rs new file mode 100644 index 0000000000..c708a88fb8 --- /dev/null +++ b/src/dom_html_prelude/detect.rs @@ -0,0 +1,615 @@ +//! Purpose: +//! Decides whether a parsed program references the Termwind-facing DOM HTML +//! surface — `DOMDocument`, `DOMNode`, `DOMElement`, `DOMText`, `DOMComment`, +//! `DOMCharacterData`, or `DOMNodeList` — so the HTML prelude is injected only +//! for programs that actually walk a loaded HTML tree. +//! +//! Called from: +//! - `crate::dom_html_prelude::inject_if_used`. +//! +//! Key details: +//! - Runs before name resolution, so `Name`s are raw source text and PHP class +//! names are case-insensitive. A reference may be written `DOMDocument`, +//! `\DOMDocument`, or `\Termwind\DOMDocument`. The walk matches the +//! unqualified last segment case-insensitively. +//! - Class-name positions trigger injection: `new`, static receivers, +//! `instanceof`, `catch`, `extends`/`implements`, type hints, trait uses, and +//! `use` imports. There is no user-facing procedural `dom_*` function set. +//! - Capability probes (`class_exists('DOMDocument')`) are string literals and +//! deliberately do NOT trigger injection — same rule as the PDO/mysqli +//! preludes. A probe-only program honestly reports that the class is absent. +//! - Soundness over precision: a missed reference would drop the prelude and +//! turn a valid program into an "undefined class" error, so the `match`es are +//! exhaustive (no wildcard arm). Adding an AST node forces this file to be +//! updated. False positives only inject declarations, which is harmless. + +use crate::names::Name; +use crate::parser::ast::{ + CallableTarget, ClassConst, ClassMethod, ClassProperty, EnumCaseDecl, Expr, ExprKind, + InstanceOfTarget, PackedField, StaticReceiver, Stmt, StmtKind, TraitAdaptation, TraitUse, + TypeExpr, +}; + +/// The OOP classes the Termwind HTML prelude declares. Last-segment, +/// case-insensitive match so `\DOMDocument` and `domdocument` both inject. +const DOM_HTML_CLASSES: &[&str] = &[ + "DOMDocument", + "DOMNode", + "DOMElement", + "DOMText", + "DOMComment", + "DOMCharacterData", + "DOMNodeList", +]; + +/// Returns whether any top-level statement references the Termwind DOM HTML +/// surface, so the prelude must be injected ahead of user code. +pub(super) fn program_uses_dom_html(program: &[Stmt]) -> bool { + program.iter().any(stmt_refs_dom) +} + +/// Returns whether `name`'s unqualified last segment is a Termwind DOM class, +/// compared case-insensitively and tolerant of any namespace/leading-backslash +/// form (`DOMDocument`, `\DOMDocument`, `\Termwind\DOMElement`). +fn name_is_dom_class(name: &Name) -> bool { + name.last_segment().is_some_and(|segment| { + DOM_HTML_CLASSES + .iter() + .any(|candidate| segment.eq_ignore_ascii_case(candidate)) + }) +} + +/// Returns whether a static receiver names a DOM class (`DOMDocument::...`). +/// `self`, `static`, and `parent` never resolve to a DOM class at this position. +fn receiver_refs_dom(receiver: &StaticReceiver) -> bool { + matches!(receiver, StaticReceiver::Named(name) if name_is_dom_class(name)) +} + +/// Returns whether an `instanceof` target references a DOM class, recursing into +/// the operand when the target is a runtime expression. +fn instanceof_target_refs_dom(target: &InstanceOfTarget) -> bool { + match target { + InstanceOfTarget::Name(name) => name_is_dom_class(name), + InstanceOfTarget::Expr(expr) => expr_refs_dom(expr), + } +} + +/// Returns whether a first-class-callable target references a DOM class through +/// a static-method receiver or an instance-method object expression. +fn callable_target_refs_dom(target: &CallableTarget) -> bool { + match target { + CallableTarget::Function(_) => false, + CallableTarget::StaticMethod { receiver, .. } => receiver_refs_dom(receiver), + CallableTarget::Method { object, .. } => expr_refs_dom(object), + } +} + +/// Returns whether a type expression names a DOM class, recursing through +/// nullable/union/array/buffer wrappers and `ptr` targets. +fn type_refs_dom(type_expr: &TypeExpr) -> bool { + match type_expr { + TypeExpr::Int + | TypeExpr::Float + | TypeExpr::Bool + | TypeExpr::False + | TypeExpr::Str + | TypeExpr::Void + | TypeExpr::Never + | TypeExpr::Iterable => false, + TypeExpr::Ptr(target) => target.as_ref().is_some_and(name_is_dom_class), + TypeExpr::Array(inner) | TypeExpr::Buffer(inner) | TypeExpr::Nullable(inner) => { + type_refs_dom(inner) + } + TypeExpr::Named(name) => name_is_dom_class(name), + TypeExpr::Union(members) | TypeExpr::Intersection(members) => { + members.iter().any(type_refs_dom) + } + } +} + +/// Returns whether any parameter's type hint or default value references the +/// hashing surface. Shared by function, method, and closure parameter lists. +fn params_ref_dom(params: &[(String, Option, Option, bool)]) -> bool { + params.iter().any(|(_, type_expr, default, _)| { + type_expr.as_ref().is_some_and(type_refs_dom) + || default.as_ref().is_some_and(expr_refs_dom) + }) +} + +/// Returns whether a `use Trait` clause names the hash class through its trait list +/// or any conflict-resolution adaptation. +fn trait_use_refs_dom(trait_use: &TraitUse) -> bool { + trait_use.trait_names.iter().any(name_is_dom_class) + || trait_use.adaptations.iter().any(|adaptation| match adaptation { + TraitAdaptation::Alias { trait_name, .. } => { + trait_name.as_ref().is_some_and(name_is_dom_class) + } + TraitAdaptation::InsteadOf { + trait_name, + instead_of, + .. + } => { + trait_name.as_ref().is_some_and(name_is_dom_class) + || instead_of.iter().any(name_is_dom_class) + } + }) +} + +/// Returns whether a class property's type hint or default value references the +/// hashing surface. +fn class_property_refs_dom(property: &ClassProperty) -> bool { + property.type_expr.as_ref().is_some_and(type_refs_dom) + || property.default.as_ref().is_some_and(expr_refs_dom) +} + +/// Returns whether a method's parameters, return type, or body reference the +/// hashing surface. +fn class_method_refs_dom(method: &ClassMethod) -> bool { + params_ref_dom(&method.params) + || method.return_type.as_ref().is_some_and(type_refs_dom) + || method.body.iter().any(stmt_refs_dom) +} + +/// Returns whether a class constant's initializer references the hashing surface. +fn class_const_refs_dom(constant: &ClassConst) -> bool { + expr_refs_dom(&constant.value) +} + +/// Returns whether an enum case's backing-value expression references the hashing +/// surface. +fn enum_case_refs_dom(case: &EnumCaseDecl) -> bool { + case.value.as_ref().is_some_and(expr_refs_dom) +} + +/// Returns whether a `packed class` field's type references a DOM class. +/// DOM nodes are never a valid packed field type, but the field is walked for +/// completeness. +fn packed_field_refs_dom(field: &PackedField) -> bool { + type_refs_dom(&field.type_expr) +} + +/// Returns whether an expression references a DOM class at any class-name +/// position, recursing into every child expression and statement. The `match` +/// is exhaustive so a newly added `ExprKind` cannot silently bypass detection. +fn expr_refs_dom(expr: &Expr) -> bool { + match &expr.kind { + // Leaves and identifier-only forms carry no DOM reference. + ExprKind::StringLiteral(_) + | ExprKind::IntLiteral(_) + | ExprKind::FloatLiteral(_) + | ExprKind::Variable(_) + | ExprKind::BoolLiteral(_) + | ExprKind::Null + | ExprKind::This + | ExprKind::PreIncrement(_) + | ExprKind::PostIncrement(_) + | ExprKind::PreDecrement(_) + | ExprKind::PostDecrement(_) + | ExprKind::ConstRef(_) + | ExprKind::MagicConstant(_) => false, + + ExprKind::BinaryOp { left, right, .. } => expr_refs_dom(left) || expr_refs_dom(right), + ExprKind::InstanceOf { value, target } => { + expr_refs_dom(value) || instanceof_target_refs_dom(target) + } + ExprKind::Negate(inner) + | ExprKind::Not(inner) + | ExprKind::BitNot(inner) + | ExprKind::Throw(inner) + | ExprKind::Clone(inner) + | ExprKind::ErrorSuppress(inner) + | ExprKind::Print(inner) + | ExprKind::Spread(inner) + | ExprKind::YieldFrom(inner) => expr_refs_dom(inner), + ExprKind::NullCoalesce { value, default } + | ExprKind::ShortTernary { value, default } => { + expr_refs_dom(value) || expr_refs_dom(default) + } + ExprKind::Pipe { value, callable } => expr_refs_dom(value) || expr_refs_dom(callable), + ExprKind::Assignment { + target, + value, + result_target, + prelude, + .. + } => { + expr_refs_dom(target) + || expr_refs_dom(value) + || result_target.as_deref().is_some_and(expr_refs_dom) + || prelude.iter().any(stmt_refs_dom) + } + ExprKind::FunctionCall { args, .. } => args.iter().any(expr_refs_dom), + ExprKind::ClosureCall { args, .. } => args.iter().any(expr_refs_dom), + ExprKind::ArrayLiteral(items) => items.iter().any(expr_refs_dom), + ExprKind::ArrayLiteralAssoc(pairs) => pairs + .iter() + .any(|(key, value)| expr_refs_dom(key) || expr_refs_dom(value)), + ExprKind::Match { + subject, + arms, + default, + } => { + expr_refs_dom(subject) + || arms.iter().any(|(conditions, body)| { + conditions.iter().any(expr_refs_dom) || expr_refs_dom(body) + }) + || default.as_deref().is_some_and(expr_refs_dom) + } + ExprKind::ArrayAccess { array, index } => expr_refs_dom(array) || expr_refs_dom(index), + ExprKind::Ternary { + condition, + then_expr, + else_expr, + } => expr_refs_dom(condition) || expr_refs_dom(then_expr) || expr_refs_dom(else_expr), + ExprKind::Cast { expr, .. } | ExprKind::PtrCast { expr, .. } => expr_refs_dom(expr), + ExprKind::Closure { + params, + return_type, + body, + .. + } => { + params_ref_dom(params) + || return_type.as_ref().is_some_and(type_refs_dom) + || body.iter().any(stmt_refs_dom) + } + ExprKind::NamedArg { value, .. } => expr_refs_dom(value), + ExprKind::ExprCall { callee, args } => { + expr_refs_dom(callee) || args.iter().any(expr_refs_dom) + } + ExprKind::NewObject { class_name, args } => { + name_is_dom_class(class_name) || args.iter().any(expr_refs_dom) + } + ExprKind::NewDynamic { name_expr, args } => { + expr_refs_dom(name_expr) || args.iter().any(expr_refs_dom) + } + ExprKind::NewDynamicObject { + class_name, + fallback_class, + required_parent, + args, + } => { + expr_refs_dom(class_name) + || name_is_dom_class(fallback_class) + || name_is_dom_class(required_parent) + || args.iter().any(expr_refs_dom) + } + ExprKind::PropertyAccess { object, .. } + | ExprKind::NullsafePropertyAccess { object, .. } => expr_refs_dom(object), + ExprKind::DynamicPropertyAccess { object, property } + | ExprKind::NullsafeDynamicPropertyAccess { object, property } => { + expr_refs_dom(object) || expr_refs_dom(property) + } + ExprKind::StaticPropertyAccess { receiver, .. } => receiver_refs_dom(receiver), + ExprKind::MethodCall { object, args, .. } + | ExprKind::NullsafeMethodCall { object, args, .. } => { + expr_refs_dom(object) || args.iter().any(expr_refs_dom) + } + ExprKind::NullsafeDynamicMethodCall { + object, + method, + args, + } => expr_refs_dom(object) || expr_refs_dom(method) || args.iter().any(expr_refs_dom), + ExprKind::StaticMethodCall { receiver, args, .. } => { + receiver_refs_dom(receiver) || args.iter().any(expr_refs_dom) + } + ExprKind::FirstClassCallable(target) => callable_target_refs_dom(target), + ExprKind::BufferNew { element_type, len } => { + type_refs_dom(element_type) || expr_refs_dom(len) + } + ExprKind::ClassConstant { receiver } + | ExprKind::ScopedConstantAccess { receiver, .. } => receiver_refs_dom(receiver), + ExprKind::ObjectClassName { object } => expr_refs_dom(object), + ExprKind::NewScopedObject { receiver, args } => { + receiver_refs_dom(receiver) || args.iter().any(expr_refs_dom) + } + ExprKind::Yield { key, value } => { + key.as_deref().is_some_and(expr_refs_dom) + || value.as_deref().is_some_and(expr_refs_dom) + } + // Transient: the resolver expands this into the included file's statements + // before hash detection runs, so it should never reach here. Recurse into the + // path expression defensively to keep detection exhaustive and correct. + ExprKind::IncludeValue { path, .. } => expr_refs_dom(path), + } +} + +/// Returns whether a statement references the hashing surface at any function-call +/// or class-name position, recursing into nested statements, expressions, and class +/// members. The `match` is exhaustive so a newly added `StmtKind` cannot silently +/// bypass detection. +fn stmt_refs_dom(stmt: &Stmt) -> bool { + match &stmt.kind { + // Statements with no hash-name position and no child expr/stmt. + StmtKind::RefAssign { .. } + | StmtKind::IncludeOnceMark { .. } + | StmtKind::Break(_) + | StmtKind::Continue(_) + | StmtKind::NamespaceDecl { .. } + | StmtKind::FunctionVariantGroup { .. } + | StmtKind::FunctionVariantMark { .. } + | StmtKind::Global { .. } + | StmtKind::ExternFunctionDecl { .. } + | StmtKind::ExternClassDecl { .. } + | StmtKind::ExternGlobalDecl { .. } => false, + + // An aliased import (`use DOMDocument as Doc;`) names the class only here; + // the later `new Doc()` / `Doc $d` carries the alias, which the walk cannot + // otherwise connect back — so skipping imports would be a false negative. + StmtKind::UseDecl { imports } => imports + .iter() + .any(|item| name_is_dom_class(&item.name)), + + StmtKind::Echo(expr) | StmtKind::Throw(expr) | StmtKind::ExprStmt(expr) => { + expr_refs_dom(expr) + } + StmtKind::Assign { value, .. } => expr_refs_dom(value), + StmtKind::If { + condition, + then_body, + elseif_clauses, + else_body, + } => { + expr_refs_dom(condition) + || then_body.iter().any(stmt_refs_dom) + || elseif_clauses + .iter() + .any(|(cond, body)| expr_refs_dom(cond) || body.iter().any(stmt_refs_dom)) + || else_body + .as_ref() + .is_some_and(|body| body.iter().any(stmt_refs_dom)) + } + StmtKind::IfDef { + then_body, + else_body, + .. + } => { + then_body.iter().any(stmt_refs_dom) + || else_body + .as_ref() + .is_some_and(|body| body.iter().any(stmt_refs_dom)) + } + StmtKind::While { condition, body } | StmtKind::DoWhile { body, condition } => { + expr_refs_dom(condition) || body.iter().any(stmt_refs_dom) + } + StmtKind::For { + init, + condition, + update, + body, + } => { + init.as_deref().is_some_and(stmt_refs_dom) + || condition.as_ref().is_some_and(expr_refs_dom) + || update.as_deref().is_some_and(stmt_refs_dom) + || body.iter().any(stmt_refs_dom) + } + StmtKind::ArrayAssign { index, value, .. } => { + expr_refs_dom(index) || expr_refs_dom(value) + } + StmtKind::NestedArrayAssign { target, value } => { + expr_refs_dom(target) || expr_refs_dom(value) + } + StmtKind::ArrayPush { value, .. } => expr_refs_dom(value), + StmtKind::TypedAssign { + type_expr, value, .. + } => type_refs_dom(type_expr) || expr_refs_dom(value), + StmtKind::Foreach { array, body, .. } => { + expr_refs_dom(array) || body.iter().any(stmt_refs_dom) + } + StmtKind::Switch { + subject, + cases, + default, + } => { + expr_refs_dom(subject) + || cases.iter().any(|(conditions, body)| { + conditions.iter().any(expr_refs_dom) || body.iter().any(stmt_refs_dom) + }) + || default + .as_ref() + .is_some_and(|body| body.iter().any(stmt_refs_dom)) + } + StmtKind::Include { path, .. } => expr_refs_dom(path), + StmtKind::IncludeOnceGuard { body, .. } + | StmtKind::Synthetic(body) + | StmtKind::NamespaceBlock { body, .. } => body.iter().any(stmt_refs_dom), + StmtKind::Try { + try_body, + catches, + finally_body, + } => { + try_body.iter().any(stmt_refs_dom) + || catches.iter().any(|catch| { + catch.exception_types.iter().any(name_is_dom_class) + || catch.body.iter().any(stmt_refs_dom) + }) + || finally_body + .as_ref() + .is_some_and(|body| body.iter().any(stmt_refs_dom)) + } + StmtKind::FunctionDecl { + params, + return_type, + body, + .. + } => { + params_ref_dom(params) + || return_type.as_ref().is_some_and(type_refs_dom) + || body.iter().any(stmt_refs_dom) + } + StmtKind::Return(value) => value.as_ref().is_some_and(expr_refs_dom), + StmtKind::ConstDecl { value, .. } => expr_refs_dom(value), + StmtKind::ListUnpack { value, .. } => expr_refs_dom(value), + StmtKind::StaticVar { init, .. } => expr_refs_dom(init), + StmtKind::ClassDecl { + extends, + implements, + trait_uses, + properties, + methods, + constants, + .. + } => { + extends.as_ref().is_some_and(name_is_dom_class) + || implements.iter().any(name_is_dom_class) + || trait_uses.iter().any(trait_use_refs_dom) + || properties.iter().any(class_property_refs_dom) + || methods.iter().any(class_method_refs_dom) + || constants.iter().any(class_const_refs_dom) + } + StmtKind::EnumDecl { + backing_type, + cases, + .. + } => { + backing_type.as_ref().is_some_and(type_refs_dom) + || cases.iter().any(enum_case_refs_dom) + } + StmtKind::PackedClassDecl { fields, .. } => fields.iter().any(packed_field_refs_dom), + StmtKind::InterfaceDecl { + extends, + properties, + methods, + constants, + .. + } => { + extends.iter().any(name_is_dom_class) + || properties.iter().any(class_property_refs_dom) + || methods.iter().any(class_method_refs_dom) + || constants.iter().any(class_const_refs_dom) + } + StmtKind::TraitDecl { + trait_uses, + properties, + methods, + constants, + .. + } => { + trait_uses.iter().any(trait_use_refs_dom) + || properties.iter().any(class_property_refs_dom) + || methods.iter().any(class_method_refs_dom) + || constants.iter().any(class_const_refs_dom) + } + StmtKind::PropertyAssign { object, value, .. } => { + expr_refs_dom(object) || expr_refs_dom(value) + } + StmtKind::StaticPropertyAssign { + receiver, value, .. + } + | StmtKind::StaticPropertyArrayPush { + receiver, value, .. + } => receiver_refs_dom(receiver) || expr_refs_dom(value), + StmtKind::StaticPropertyArrayAssign { + receiver, + index, + value, + .. + } => receiver_refs_dom(receiver) || expr_refs_dom(index) || expr_refs_dom(value), + StmtKind::PropertyArrayPush { object, value, .. } => { + expr_refs_dom(object) || expr_refs_dom(value) + } + StmtKind::PropertyArrayAssign { + object, + index, + value, + .. + } => expr_refs_dom(object) || expr_refs_dom(index) || expr_refs_dom(value), + } +} + +#[cfg(test)] +mod tests { + //! Purpose: + //! Unit tests for the Termwind DOM HTML AST walk: every DOM class-name + //! position is detected across `\`-qualified and mixed-case spellings, while + //! string-literal probes and unrelated programs are not. + //! + //! Called from: + //! - `cargo test` through Rust's test harness. + //! + //! Key details: + //! - Tests parse raw source (pre name-resolution), matching the stage at which + //! `program_uses_dom_html` runs inside `inject_if_used`. + + use super::*; + + /// Parses source the same way `inject_if_used` sees it: tokenize then parse, + /// before any name resolution. + fn parse(source: &str) -> Vec { + let tokens = crate::lexer::tokenize(source).expect("test source must tokenize"); + crate::parser::parse(&tokens).expect("test source must parse") + } + + /// `new DOMDocument` is the Termwind entry point and must inject the prelude. + #[test] + fn detects_new_dom_document() { + assert!(program_uses_dom_html(&parse( + "= strlen($s)) { + return ""; + } + return substr($s, $i, 1); +} + +function __elephc_dom_is_ws(string $c): bool { + return $c === " " || $c === "\t" || $c === "\n" || $c === "\r"; +} + +function __elephc_dom_is_name(string $c): bool { + if ($c === "" ) { + return false; + } + $_o = ord($c); + if ($_o >= 65 && $_o <= 90) { + return true; + } + if ($_o >= 97 && $_o <= 122) { + return true; + } + if ($_o >= 48 && $_o <= 57) { + return true; + } + return $c === "-" || $c === "_" || $c === ":"; +} + +function __elephc_dom_is_void(string $name): bool { + return $name === "area" || $name === "base" || $name === "br" || $name === "col" + || $name === "embed" || $name === "hr" || $name === "img" || $name === "input" + || $name === "link" || $name === "meta" || $name === "param" || $name === "source" + || $name === "track" || $name === "wbr"; +} + +function __elephc_dom_skip_ws(string $html, int $i): int { + $_len = strlen($html); + while ($i < $_len && __elephc_dom_is_ws(__elephc_dom_char($html, $i))) { + $i = $i + 1; + } + return $i; +} + +function __elephc_dom_read_name(string $html, int $i): array { + $_start = $i; + $_len = strlen($html); + while ($i < $_len && __elephc_dom_is_name(__elephc_dom_char($html, $i))) { + $i = $i + 1; + } + return [strtolower(substr($html, $_start, $i - $_start)), $i]; +} + +function __elephc_dom_read_attr_value(string $html, int $i): array { + $_len = strlen($html); + $i = __elephc_dom_skip_ws($html, $i); + $_q = __elephc_dom_char($html, $i); + if ($_q === "\"" || $_q === "'") { + $i = $i + 1; + $_start = $i; + while ($i < $_len && __elephc_dom_char($html, $i) !== $_q) { + $i = $i + 1; + } + $_val = html_entity_decode(substr($html, $_start, $i - $_start)); + if ($i < $_len) { + $i = $i + 1; + } + return [$_val, $i]; + } + $_start = $i; + while ($i < $_len) { + $_c = __elephc_dom_char($html, $i); + if (__elephc_dom_is_ws($_c) || $_c === ">" || $_c === "/") { + break; + } + $i = $i + 1; + } + return [html_entity_decode(substr($html, $_start, $i - $_start)), $i]; +} + +function __elephc_dom_read_attrs(string $html, int $i): array { + $_attrs = []; + $_self = 0; + $_len = strlen($html); + while ($i < $_len) { + $i = __elephc_dom_skip_ws($html, $i); + $_c = __elephc_dom_char($html, $i); + if ($_c === "" || $_c === ">") { + if ($_c === ">") { + $i = $i + 1; + } + break; + } + if ($_c === "/") { + $_self = 1; + $i = $i + 1; + $i = __elephc_dom_skip_ws($html, $i); + if (__elephc_dom_char($html, $i) === ">") { + $i = $i + 1; + } + break; + } + $_pair = __elephc_dom_read_name($html, $i); + $_name = $_pair[0]; + $i = $_pair[1]; + if ($_name === "") { + $i = $i + 1; + continue; + } + $i = __elephc_dom_skip_ws($html, $i); + $_val = ""; + if (__elephc_dom_char($html, $i) === "=") { + $i = $i + 1; + $_av = __elephc_dom_read_attr_value($html, $i); + $_val = $_av[0]; + $i = $_av[1]; + } + $_attrs[$_name] = $_val; + } + return [$_attrs, $_self, $i]; +} + +function __elephc_dom_tokenize(string $html, int $flags): array { + $_tokens = []; + $_i = 0; + $_len = strlen($html); + $_noblanks = ($flags & 256) !== 0; + while ($_i < $_len) { + $_c = __elephc_dom_char($html, $_i); + if ($_c !== "<") { + $_start = $_i; + while ($_i < $_len && __elephc_dom_char($html, $_i) !== "<") { + $_i = $_i + 1; + } + $_text = html_entity_decode(substr($html, $_start, $_i - $_start)); + if ($_noblanks && trim($_text) === "") { + continue; + } + if ($_text !== "") { + $_tokens[] = ["k" => "text", "v" => $_text]; + } + continue; + } + if (substr($html, $_i, 4) === "", $_i); + if ($_end === false) { + $_body = substr($html, $_i); + $_i = $_len; + } else { + $_body = substr($html, $_i, $_end - $_i); + $_i = $_end + 3; + } + $_tokens[] = ["k" => "comment", "v" => $_body]; + continue; + } + if (substr($html, $_i, 2) === "", $_i); + if ($_gt === false) { + break; + } + $_i = $_gt + 1; + continue; + } + if (substr($html, $_i, 2) === "", $_i); + if ($_gt === false) { + $_i = $_len; + } else { + $_i = $_gt + 1; + } + $_tokens[] = ["k" => "end", "n" => $_name]; + continue; + } + $_i = $_i + 1; + $_pair = __elephc_dom_read_name($html, $_i); + $_name = $_pair[0]; + $_i = $_pair[1]; + if ($_name === "") { + $_tokens[] = ["k" => "text", "v" => "<"]; + continue; + } + $_ap = __elephc_dom_read_attrs($html, $_i); + $_attrs = $_ap[0]; + $_self = $_ap[1]; + $_i = $_ap[2]; + if ($_self === 1 || __elephc_dom_is_void($_name)) { + $_self = 1; + } + $_tokens[] = ["k" => "start", "n" => $_name, "a" => $_attrs, "void" => $_self]; + } + return $_tokens; +} + +function __elephc_dom_text_of(array $node): string { + if ($node["kind"] === "text" || $node["kind"] === "comment") { + return (string) $node["value"]; + } + $_out = ""; + foreach ($node["children"] as $_child) { + $_out = $_out . __elephc_dom_text_of($_child); + } + return $_out; +} + +function __elephc_dom_new_node(string $kind, string $name, string $value, array $attrs, array $children): array { + return [ + "kind" => $kind, + "name" => $name, + "value" => $value, + "attrs" => $attrs, + "children" => $children, + ]; +} + +function __elephc_dom_append_child(array $stack, array $node): array { + $_top = $stack[count($stack) - 1]; + $_children = $_top["children"]; + $_children[] = $node; + $_top["children"] = $_children; + $stack[count($stack) - 1] = $_top; + return $stack; +} + +function __elephc_dom_build_forest(array $tokens): array { + $_stack = []; + $_roots = []; + foreach ($tokens as $_tok) { + $_k = $_tok["k"]; + if ($_k === "text") { + $_node = __elephc_dom_new_node("text", "#text", (string) $_tok["v"], [], []); + if (count($_stack) === 0) { + $_roots[] = $_node; + } else { + $_stack = __elephc_dom_append_child($_stack, $_node); + } + } elseif ($_k === "comment") { + $_node = __elephc_dom_new_node("comment", "#comment", (string) $_tok["v"], [], []); + if (count($_stack) === 0) { + $_roots[] = $_node; + } else { + $_stack = __elephc_dom_append_child($_stack, $_node); + } + } elseif ($_k === "start") { + $_node = __elephc_dom_new_node("element", (string) $_tok["n"], "", $_tok["a"], []); + if ((int) $_tok["void"] === 1) { + if (count($_stack) === 0) { + $_roots[] = $_node; + } else { + $_stack = __elephc_dom_append_child($_stack, $_node); + } + } else { + $_stack[] = $_node; + } + } else { + if (count($_stack) === 0) { + continue; + } + $_done = array_pop($_stack); + if (count($_stack) === 0) { + $_roots[] = $_done; + } else { + $_stack = __elephc_dom_append_child($_stack, $_done); + } + } + } + while (count($_stack) > 0) { + $_done = array_pop($_stack); + if (count($_stack) === 0) { + $_roots[] = $_done; + } else { + $_stack = __elephc_dom_append_child($_stack, $_done); + } + } + return $_roots; +} + +function __elephc_dom_find_child(array $node, string $name): mixed { + foreach ($node["children"] as $_child) { + if ($_child["kind"] === "element" && $_child["name"] === $name) { + return $_child; + } + } + return null; +} + +function __elephc_dom_wrap_html(array $roots): array { + if (count($roots) === 1 && $roots[0]["kind"] === "element" && $roots[0]["name"] === "html") { + $_html = $roots[0]; + if (__elephc_dom_find_child($_html, "body") === null) { + $_html["children"] = [__elephc_dom_new_node("element", "body", "", [], $_html["children"])]; + } + return $_html; + } + if (count($roots) === 1 && $roots[0]["kind"] === "element" && $roots[0]["name"] === "body") { + return __elephc_dom_new_node("element", "html", "", [], $roots); + } + $_body = __elephc_dom_new_node("element", "body", "", [], $roots); + return __elephc_dom_new_node("element", "html", "", [], [$_body]); +} + +function __elephc_dom_parse_html(string $html, int $flags): array { + $_tokens = __elephc_dom_tokenize($html, $flags); + $_roots = __elephc_dom_build_forest($_tokens); + $_html = __elephc_dom_wrap_html($_roots); + $_html["value"] = __elephc_dom_text_of($_html); + return __elephc_dom_new_node("document", "#document", $_html["value"], [], [$_html]); +} + +function __elephc_dom_escape(string $s): string { + return htmlspecialchars($s); +} + +function __elephc_dom_serialize_node(mixed $node): string { + if ($node instanceof DOMText) { + return __elephc_dom_escape((string) $node->nodeValue); + } + if ($node instanceof DOMComment) { + return ""; + } + if ($node instanceof DOMDocument) { + $_out = ""; + if ($node->childNodes !== null) { + foreach ($node->childNodes as $_child) { + $_out = $_out . __elephc_dom_serialize_node($_child); + } + } + return $_out; + } + $_out = "<" . $node->nodeName; + foreach ($node->__attrs as $_k => $_v) { + $_out = $_out . " " . $_k . "=\"" . __elephc_dom_escape((string) $_v) . "\""; + } + $_inner = ""; + if ($node->childNodes !== null) { + foreach ($node->childNodes as $_child) { + $_inner = $_inner . __elephc_dom_serialize_node($_child); + } + } + if ($_inner === "" && __elephc_dom_is_void($node->nodeName)) { + return $_out . "/>"; + } + return $_out . ">" . $_inner . "nodeName . ">"; +} + +function __elephc_dom_wire(array $siblings, mixed $parent): void { + $_n = count($siblings); + $_doc = $parent; + if (!($parent instanceof DOMDocument)) { + $_doc = $parent->ownerDocument; + } + for ($_i = 0; $_i < $_n; $_i++) { + $_node = $siblings[$_i]; + $_node->parentNode = $parent; + $_node->ownerDocument = $_doc; + if ($_i > 0) { + $_node->previousSibling = $siblings[$_i - 1]; + } else { + $_node->previousSibling = null; + } + if ($_i + 1 < $_n) { + $_node->nextSibling = $siblings[$_i + 1]; + } else { + $_node->nextSibling = null; + } + } +} + +function __elephc_dom_make_node(DOMDocument $doc, array $tree): mixed { + $_kind = (string) $tree["kind"]; + if ($_kind === "text") { + $_n = new DOMText(); + $_n->nodeName = "#text"; + $_n->nodeValue = (string) $tree["value"]; + $_n->ownerDocument = $doc; + $_n->childNodes = new DOMNodeList([]); + return $_n; + } + if ($_kind === "comment") { + $_n = new DOMComment(); + $_n->nodeName = "#comment"; + $_n->nodeValue = (string) $tree["value"]; + $_n->ownerDocument = $doc; + $_n->childNodes = new DOMNodeList([]); + return $_n; + } + $_n = new DOMElement(); + $_n->nodeName = (string) $tree["name"]; + $_n->__attrs = $tree["attrs"]; + $_n->ownerDocument = $doc; + $_kids = []; + foreach ($tree["children"] as $_child) { + $_kids[] = __elephc_dom_make_node($doc, $_child); + } + $_n->childNodes = new DOMNodeList($_kids); + __elephc_dom_wire($_kids, $_n); + $_n->nodeValue = __elephc_dom_text_of($tree); + return $_n; +} + +function __elephc_dom_collect(mixed $node, string $name): array { + $_found = []; + if ($node->childNodes === null) { + return $_found; + } + foreach ($node->childNodes as $_child) { + if ($_child instanceof DOMElement) { + if ($name === "*" || $_child->nodeName === $name) { + $_found[] = $_child; + } + $_more = __elephc_dom_collect($_child, $name); + foreach ($_more as $_item) { + $_found[] = $_item; + } + } + } + return $_found; +} + +class DOMNode { + public string $nodeName = ""; + public mixed $nodeValue = null; + public mixed $childNodes = null; + public mixed $previousSibling = null; + public mixed $nextSibling = null; + public mixed $parentNode = null; + public mixed $ownerDocument = null; + public array $__attrs = []; + + public function getAttribute(string $name): string { + $_key = strtolower($name); + if (array_key_exists($_key, $this->__attrs)) { + return (string) $this->__attrs[$_key]; + } + return ""; + } + + public function getElementsByTagName(string $qualifiedName): DOMNodeList { + return new DOMNodeList(__elephc_dom_collect($this, strtolower($qualifiedName))); + } +} + +class DOMNodeList implements Iterator { + public int $length = 0; + public array $__items = []; + private int $__i = 0; + + public function __construct(array $items = []) { + $this->__items = $items; + $this->length = count($items); + $this->__i = 0; + } + + public function item(int $index): mixed { + if ($index < 0 || $index >= $this->length) { + return null; + } + return $this->__items[$index]; + } + + public function rewind(): void { + $this->__i = 0; + } + + public function valid(): bool { + return $this->__i < $this->length; + } + + public function current(): mixed { + return $this->__items[$this->__i]; + } + + public function key(): mixed { + return $this->__i; + } + + public function next(): void { + $this->__i = $this->__i + 1; + } +} + +class DOMDocument extends DOMNode { + public int $__elephc_flags = 0; + + public function __construct(string $version = "1.0", string $encoding = "") { + $_unused_version = $version; + $_unused_encoding = $encoding; + $this->nodeName = "#document"; + $this->nodeValue = null; + $this->childNodes = new DOMNodeList([]); + $this->ownerDocument = $this; + } + + public function loadHTML(string $source, int $options = 0): bool { + $this->__elephc_flags = $options; + $_tree = __elephc_dom_parse_html($source, $options); + $this->nodeName = "#document"; + $this->ownerDocument = $this; + $_kids = []; + foreach ($_tree["children"] as $_child) { + $_kids[] = __elephc_dom_make_node($this, $_child); + } + $this->childNodes = new DOMNodeList($_kids); + __elephc_dom_wire($_kids, $this); + $this->nodeValue = (string) $_tree["value"]; + return true; + } + + public function saveXML(mixed $node = null): string { + $_unused_flags = $this->__elephc_flags; + if ($node === null) { + return __elephc_dom_serialize_node($this); + } + return __elephc_dom_serialize_node($node); + } +} + +class DOMElement extends DOMNode { +} + +class DOMCharacterData extends DOMNode { +} + +class DOMText extends DOMCharacterData { +} + +class DOMComment extends DOMCharacterData { +} +"###; diff --git a/src/lib.rs b/src/lib.rs index 36e84913f1..927faede4b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,8 @@ pub mod codegen_support; pub mod conditional; /// `ext/curl` easy-handle standard-library prelude injection (`CurlHandle` + `curl_*`). pub mod curl_prelude; +/// Termwind-facing DOM HTML subset (`DOMDocument::loadHTML` and node walk). +pub mod dom_html_prelude; /// Error and warning reporting. pub mod errors; mod eval_aot; diff --git a/src/main.rs b/src/main.rs index 5f4dab9dbe..976a6293a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod codegen; mod codegen_support; mod conditional; mod curl_prelude; +mod dom_html_prelude; mod errors; mod eval_aot; mod exports; diff --git a/src/pipeline.rs b/src/pipeline.rs index 7817f5b935..62303c1b92 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -361,6 +361,14 @@ pub(crate) fn compile(config: CliConfig) { }; timings.record_since("curl-prelude", phase_started); + // Inject the Termwind-facing DOM HTML subset only when the program names a + // DOM class, so other binaries never carry the HTML walker. This is an + // interim prelude (choice B vs draft PR #654's native libxml stack). + crate::progress::phase("dom-html-prelude"); + let phase_started = Instant::now(); + let ast = crate::dom_html_prelude::inject_if_used(ast, false, &mut prelude_inventory); + timings.record_since("dom-html-prelude", phase_started); + crate::progress::phase("web-prelude"); let phase_started = Instant::now(); let ast = web_prelude::inject_if_web( diff --git a/tests/codegen/support/compiler.rs b/tests/codegen/support/compiler.rs index 4bfcc6722b..140ec75182 100644 --- a/tests/codegen/support/compiler.rs +++ b/tests/codegen/support/compiler.rs @@ -287,6 +287,7 @@ fn try_compile_source_to_asm_with_defines_repr( elephc::image_prelude::inject_if_used(resolved, false, &mut prelude_inventory); let resolved = elephc::hash_prelude::inject_if_used(resolved, false, &mut prelude_inventory); let resolved = elephc::curl_prelude::inject_if_used(resolved, false, &mut prelude_inventory); + let resolved = elephc::dom_html_prelude::inject_if_used(resolved, false, &mut prelude_inventory); let resolved = elephc::name_resolver::resolve(resolved).expect("name resolve failed"); let resolved = elephc::autoload::run(resolved, dir, &autoload_registry).expect("autoload failed"); diff --git a/tests/error_tests.rs b/tests/error_tests.rs index e5a46ba1f3..6c4def9cb0 100644 --- a/tests/error_tests.rs +++ b/tests/error_tests.rs @@ -67,6 +67,7 @@ fn check_source_with_defines_and_options( let mut prelude_inventory = elephc::optimize::reachability::PreludeInventory::new(); let ast = elephc::hash_prelude::inject_if_used(ast, false, &mut prelude_inventory); let ast = elephc::curl_prelude::inject_if_used(ast, false, &mut prelude_inventory); + let ast = elephc::dom_html_prelude::inject_if_used(ast, false, &mut prelude_inventory); let ast = elephc::name_resolver::resolve(ast).map_err(|e| e.message.clone())?; // Mirrors `pipeline::compile`: `func_num_args`/`func_get_args`/`func_get_arg` are // desugared into a hidden variadic parameter plus plain PHP before the checker runs, so @@ -85,6 +86,7 @@ fn check_source_full(src: &str) -> Result Date: Sat, 5 Sep 2026 10:25:46 +0000 Subject: [PATCH 3/7] test: cover loadHTML body walk and attributes Add codegen fixtures for Termwind's div-class tree walk, childNodes foreach, siblings, LIBXML_NOBLANKS, saveXML, and a namespaced call shape, plus a small example. Co-authored-by: Vincenzo Petrucci --- examples/dom-html/.gitignore | 3 + examples/dom-html/main.php | 17 ++++ tests/codegen/dom_html.rs | 149 +++++++++++++++++++++++++++++++++++ tests/codegen/mod.rs | 1 + 4 files changed, 170 insertions(+) create mode 100644 examples/dom-html/.gitignore create mode 100644 examples/dom-html/main.php create mode 100644 tests/codegen/dom_html.rs diff --git a/examples/dom-html/.gitignore b/examples/dom-html/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/dom-html/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/dom-html/main.php b/examples/dom-html/main.php new file mode 100644 index 0000000000..d63605a16f --- /dev/null +++ b/examples/dom-html/main.php @@ -0,0 +1,17 @@ +Hi'; +$dom->loadHTML( + $html, + LIBXML_NOERROR | LIBXML_COMPACT | LIBXML_HTML_NODEFDTD | LIBXML_NOBLANKS | LIBXML_NOXMLDECL +); + +$body = $dom->getElementsByTagName("body")->item(0); +foreach ($body->childNodes as $node) { + echo $node->nodeName; + echo " class="; + echo $node->getAttribute("class"); + echo " text="; + echo $node->nodeValue; + echo "\n"; +} diff --git a/tests/codegen/dom_html.rs b/tests/codegen/dom_html.rs new file mode 100644 index 0000000000..eeba554355 --- /dev/null +++ b/tests/codegen/dom_html.rs @@ -0,0 +1,149 @@ +//! Purpose: +//! Integration tests for the Termwind-facing DOM HTML subset: `loadHTML`, +//! `getElementsByTagName('body')`, child walks, attributes, node types, and +//! sibling pointers. +//! +//! Called from: +//! - `cargo test` through Rust's test harness. +//! +//! Key details: +//! - Fixtures compile PHP to a native binary and assert stdout. They cover the +//! `HtmlRenderer::parse` tree walk for a simple styled `div`, not tables or +//! code renderers. + +use crate::support::*; + +/// Verifies Termwind's `LIBXML_*` flag integers match php-src / libxml2. +#[test] +fn test_libxml_termwind_flag_values() { + let out = compile_and_run( + "loadHTML('
Hi
', LIBXML_NOERROR | LIBXML_COMPACT | LIBXML_HTML_NODEFDTD | LIBXML_NOBLANKS | LIBXML_NOXMLDECL); +$body = $dom->getElementsByTagName('body')->item(0); +echo $body->nodeName; +"#, + ); + assert_eq!(out, "body"); +} + +/// Verifies the Termwind-style walk: body child is a `div` with class and text. +#[test] +fn test_termwind_div_class_walk() { + let out = compile_and_run( + r#"Hi'; +$dom->loadHTML($html, LIBXML_NOERROR | LIBXML_COMPACT | LIBXML_HTML_NODEFDTD | LIBXML_NOBLANKS | LIBXML_NOXMLDECL); +$body = $dom->getElementsByTagName('body')->item(0); +$div = $body->childNodes->item(0); +echo $div instanceof DOMElement ? 'E' : 'x'; +echo ':'; +echo $div->nodeName; +echo ':'; +echo $div->getAttribute('class'); +echo ':'; +$text = $div->childNodes->item(0); +echo $text instanceof DOMText ? 'T' : 'x'; +echo ':'; +echo $text->nodeValue; +"#, + ); + assert_eq!(out, "E:div:text-green-500:T:Hi"); +} + +/// Verifies `foreach` over `childNodes` matches Termwind's `Node::getChildNodes`. +#[test] +fn test_child_nodes_foreach() { + let out = compile_and_run( + r#"loadHTML('
AB
', LIBXML_NOERROR | LIBXML_NOBLANKS); +$body = $dom->getElementsByTagName('body')->item(0); +$div = $body->childNodes->item(0); +foreach ($div->childNodes as $child) { + echo $child->nodeName, ':', $child->nodeValue, ';'; +} +"#, + ); + assert_eq!(out, "span:A;b:B;"); +} + +/// Verifies `previousSibling` / `nextSibling` and comment node identity. +#[test] +fn test_siblings_and_comment() { + let out = compile_and_run( + r#"loadHTML('
AB
', LIBXML_NOERROR | LIBXML_NOBLANKS); +$div = $dom->getElementsByTagName('div')->item(0); +$first = $div->childNodes->item(0); +$comment = $first->nextSibling; +$last = $comment->nextSibling; +echo $first->nodeValue; +echo $comment instanceof DOMComment ? 'C' : 'x'; +echo $comment->nodeValue; +echo $last->nodeValue; +echo $last->previousSibling === $comment ? 'P' : 'x'; +echo $first->previousSibling === null ? 'N' : 'x'; +"#, + ); + assert_eq!(out, "ACcBPN"); +} + +/// Verifies `LIBXML_NOBLANKS` drops whitespace-only text nodes between elements. +#[test] +fn test_noblanks_drops_whitespace_text() { + let out = compile_and_run( + r#"loadHTML("
\n x\n
", LIBXML_NOERROR | LIBXML_NOBLANKS); +$div = $dom->getElementsByTagName('div')->item(0); +echo $div->childNodes->length; +echo ':'; +echo $div->childNodes->item(0)->nodeName; +"#, + ); + assert_eq!(out, "1:span"); +} + +/// Verifies `saveXML` on a child and `ownerDocument` identity for Termwind `getHtml`. +#[test] +fn test_save_xml_and_owner_document() { + let out = compile_and_run( + r#"loadHTML('
Hi
', LIBXML_NOERROR | LIBXML_NOBLANKS | LIBXML_NOXMLDECL); +$div = $dom->getElementsByTagName('div')->item(0); +echo $div->ownerDocument instanceof DOMDocument ? 'D' : 'x'; +echo ':'; +echo $div->ownerDocument->saveXML($div); +"#, + ); + assert_eq!(out, "D:
Hi
"); +} + +/// Verifies namespaced unqualified `LIBXML_*` and `\DOMDocument` as Termwind spells them. +#[test] +fn test_namespaced_termwind_call_shape() { + let out = compile_and_run( + r#"loadHTML('Ok', LIBXML_NOERROR | LIBXML_NOBLANKS); +$body = $dom->getElementsByTagName('body')->item(0); +$el = $body->childNodes->item(0); +echo $el->nodeName, ':', $el->getAttribute('class'), ':', $el->nodeValue; +"#, + ); + assert_eq!(out, "span:font-bold:Ok"); +} diff --git a/tests/codegen/mod.rs b/tests/codegen/mod.rs index ff74785c06..9df6752ffe 100644 --- a/tests/codegen/mod.rs +++ b/tests/codegen/mod.rs @@ -82,6 +82,7 @@ mod static_class_features; mod types; mod optimizer; mod iterators; +mod dom_html; mod spl; mod generators; mod dead_strip; From 1f3854cc9bcc47cd0675f8772dd2080a305c3693 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 10:25:48 +0000 Subject: [PATCH 4/7] docs: document the Termwind DOM HTML subset Describe the supported walk, LIBXML flags, and remaining gaps versus full PHP DOM / draft PR #654. Co-authored-by: Vincenzo Petrucci --- ROADMAP.md | 1 + docs/README.md | 1 + docs/php/dom-html.md | 77 ++++++++++++++++++++++++++++++++++++++++++++ docs/php/eval.md | 2 ++ 4 files changed, 81 insertions(+) create mode 100644 docs/php/dom-html.md diff --git a/ROADMAP.md b/ROADMAP.md index 99f78a6d76..a68b9089dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -942,6 +942,7 @@ and 0.x validation rather than by speculative pass work. - [ ] Concat scratch-state optimization — remove or coalesce statement-boundary `concat_reset` operations when dataflow proves no scratch string can be live or consumed between resets, including arithmetic-only loop bodies, while preserving calls, output, exceptions, `eval`, and every path that can observe or reuse concat storage. - [ ] Late target-aware instruction selection and machine peepholes — add strength reduction for profitable constant arithmetic (for example `x * 31` → `(x << 5) - x` when target costs justify it), direct compare-and-branch lowering without materializing temporary booleans, redundant move/jump cleanup, and block layout that keeps cold overflow paths out of hot loop fallthrough. Validate assembly shape and behavior on AArch64 and x86_64. +- [x] Termwind-facing DOM HTML subset — `DOMDocument::loadHTML()` with the `LIBXML_*` flags Termwind ORs, `getElementsByTagName` / `DOMNodeList`, and the `DOMNode` / `DOMElement` / `DOMText` / `DOMComment` walk (`nodeName`, `nodeValue`, `childNodes`, `getAttribute`, siblings, `saveXML`). Interim pay-for-use prelude; full PHP 8.5 DOM remains #654 / #622 - [x] Whole-program declaration reachability — drop unreachable functions, unused classes, and unused methods (including compiler preludes such as PDO) after AST DCE, with conservative keep-all behavior for `eval`, dynamic calls, `unserialize`, and Reflection, and `--with-` force-keep for forced prelude groups - [x] Curated native dependencies v1: `elephc native add/install/update/remove/list/doctor/prune`, exact comment-preserving manifests and deterministic locks, content-addressed target/ABI/toolchain cache, transactional verified source builds, explicit cache cleanup, and read-only compile-time resolution. The catalog pins PCRE2 10.47, zlib 1.3.2, OpenSSL 3.5.8, nghttp2 1.70.0, libssh2 1.11.1, and curl 8.21.0, including declared transitive dependencies and fixed static link order. This remains separate from Composer packages, Rust bridge crates, user `extern` linking, and toolchain installation. diff --git a/docs/README.md b/docs/README.md index 03c43ca8d6..5f2c138346 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,6 +66,7 @@ Standard PHP features supported by elephc. Implemented PHP syntax is intended to - [Calendar](php/calendar.md) — `ext/calendar`: Julian Day conversions for the Gregorian, Julian, French Republican and Jewish calendars, Easter, day/month names, `cal_*` dispatch - [Images](php/image.md) — GD image creation, I/O, color, drawing, text, transforms/filters, Exif/IPTC metadata, the Imagick (`Imagick`/`ImagickDraw`/`ImagickPixel`/`ImagickPixelIterator`/`ImagickKernel`) and Gmagick (`Gmagick`/`GmagickDraw`/`GmagickPixel`) object APIs, and Cairo 2D vector drawing (`CairoImageSurface`/`CairoContext`/`CairoMatrix`/patterns/gradients), plus `getimagesize`/`image_type_to_*`, backed by a pure-Rust codec/raster bridge (no system GD/ImageMagick/GraphicsMagick/cairo/libpng/libjpeg/libexif) - [cURL](php/curl.md) — `ext/curl`'s complete function, class, and constant surface (easy, multi, share, `CURLFile`/`CURLStringFile` uploads, six libcurl callbacks) on a statically pinned libcurl 8.21.0 with OpenSSL 3.5.8 as its TLS backend and native Apple SecTrust verification on iOS, plus the protocol matrix, the option-rejection table, and every documented difference from PHP +- [DOM HTML (Termwind subset)](php/dom-html.md) — `DOMDocument::loadHTML()` and the node walk Termwind's `HtmlRenderer` needs; not full PHP DOM (see PR #654) ## Beyond PHP diff --git a/docs/php/dom-html.md b/docs/php/dom-html.md new file mode 100644 index 0000000000..b1aced3ab6 --- /dev/null +++ b/docs/php/dom-html.md @@ -0,0 +1,77 @@ +--- +title: "DOM HTML (Termwind subset)" +description: "A pay-for-use DOMDocument HTML fragment walker for Termwind-style tree walks, not full PHP DOM." +sidebar: + order: 24 +--- + +elephc injects a small **HTML-only DOM subset** when a program names `DOMDocument`, +`DOMNode`, `DOMElement`, `DOMText`, `DOMComment`, `DOMCharacterData`, or +`DOMNodeList`. The surface is enough for Termwind's `HtmlRenderer::parse` to +walk a fragment such as `
Hi
`: load the HTML, +take `body`, and read tag names, attributes, text, comments, and siblings. + +This is **not** a second native libxml/DOM stack. Draft +[PR #654](https://github.com/illegalstudio/elephc/pull/654) (issue +[#622](https://github.com/illegalstudio/elephc/issues/622)) remains the path to +full PHP 8.5 DOM, libxml, and SimpleXML on a statically linked `elephc-dom` +bridge. This prelude uses the same class names and `LIBXML_*` integers so that +work can replace it without changing Termwind call sites. + +```php +loadHTML( + '
Hi
', + LIBXML_NOERROR | LIBXML_COMPACT | LIBXML_HTML_NODEFDTD | LIBXML_NOBLANKS | LIBXML_NOXMLDECL +); +$body = $dom->getElementsByTagName('body')->item(0); +foreach ($body->childNodes as $node) { + echo $node->nodeName, ' ', $node->getAttribute('class'), ' ', $node->nodeValue; +} +``` + +## Supported surface + +| Piece | Behavior | +|---|---| +| `new DOMDocument()` | Empty document. Optional constructor args are accepted and ignored. | +| `loadHTML(string $source, int $options = 0): bool` | Forgiving HTML fragment parse. Wraps content in `html`/`body` the way PHP's HTML parser does. | +| `getElementsByTagName(string $name): DOMNodeList` | Document-order descendant elements. `*` matches every element. | +| `DOMNodeList::item(int $index)` / `$length` / `foreach` | Indexed access and `Iterator` traversal. | +| `nodeName`, `nodeValue` | HTML tag names are lowercased. Element `nodeValue` is concatenated descendant text. | +| `childNodes`, `previousSibling`, `nextSibling`, `parentNode`, `ownerDocument` | Wired after parse. The tree is treated as immutable. | +| `DOMElement::getAttribute(string $name): string` | Attribute names are lowercased on parse. Missing attributes return `""`. | +| `instanceof DOMElement` / `DOMText` / `DOMComment` / `DOMDocument` | Class hierarchy matches PHP (`DOMText`/`DOMComment` extend `DOMCharacterData` extend `DOMNode`). | +| `saveXML(?DOMNode $node = null): string` | Serializes a node or the whole document. No XML declaration (Termwind passes `LIBXML_NOXMLDECL`). | + +### `LIBXML_*` flags Termwind uses + +These constants are always available (including inside a namespace, and in +`eval()`), with php-src's integer values: + +| Constant | Value | Effect here | +|---|---|---| +| `LIBXML_NOXMLDECL` | 2 | Save flag; parse ignores it. `saveXML()` never emits ``. | +| `LIBXML_HTML_NODEFDTD` | 4 | No default doctype is added (none is added anyway). | +| `LIBXML_NOERROR` | 32 | Parse is silent and forgiving. | +| `LIBXML_NOBLANKS` | 256 | Whitespace-only text nodes are dropped. | +| `LIBXML_COMPACT` | 65536 | Accepted and ignored (libxml compaction is an optimizer hint). | + +## Remaining gaps versus full PHP DOM + +Tracked against PHP's `ext/dom` / `ext/libxml` and against #654: + +- No libxml2 or Lexbor engine, no `elephc-dom` crate, no XML `load()` / `loadXML()`. +- No modern `Dom\` HTML API (`Dom\HTMLDocument`, `Dom\Element`, …). +- No XPath, CSS selectors, DTD/schema validation, XInclude, C14N, or token lists. +- No tree mutation (`appendChild`, `removeChild`, `setAttribute`, `createElement`, …). +- No `DOMAttr` / `DOMNamedNodeMap`, processing instructions, CDATA, entities, or notations. +- No `LIBXML_*` constants beyond the five Termwind flags. +- No SimpleXML and no DOM ↔ SimpleXML import. +- `loadHTMLFile()`, encoding detection, error collection (`libxml_use_internal_errors`), and default `` insertion are unimplemented. +- Specialized Termwind renderers that need richer markup (``, ``, `
` via `getHtml()` / `saveXML` of mixed subtrees) are not the goal of this subset; `saveXML` covers a simple element so those paths can be added later.
+- The class surface is AOT-prelude only. `eval()` sees the `LIBXML_*` constants but not the DOM classes until #654 or a Magician binding lands.
+
+Do not vendor Termwind itself. Compile programs that already depend on Termwind
+against this subset; keep Termwind's sources in the application tree.
diff --git a/docs/php/eval.md b/docs/php/eval.md
index 1012e2fb75..3cdd54ee22 100644
--- a/docs/php/eval.md
+++ b/docs/php/eval.md
@@ -898,6 +898,8 @@ value, return `false`, and emit the same suppressible duplicate-constant warning
 as AOT `define()`.
 
 Eval predefined constants include `PHP_EOL`, `PHP_OS`, `DIRECTORY_SEPARATOR`,
+the Termwind-facing `LIBXML_NOERROR` / `LIBXML_COMPACT` / `LIBXML_HTML_NODEFDTD` /
+`LIBXML_NOBLANKS` / `LIBXML_NOXMLDECL` flags,
 `PHP_INT_MAX`, `INF`, `NAN`, the `PHP_VERSION*` / `PHP_SAPI` version surface,
 `PATHINFO_*`, `PHP_URL_*`, `FNM_*`, `ARRAY_FILTER_USE_*`, `COUNT_*`, and the supported
 `PREG_*` / `JSON_*` constants. `defined()` sees these names, including an

From 10b19853aea357285362ef7a1705146ad6a98bc2 Mon Sep 17 00:00:00 2001
From: Cursor Agent 
Date: Sat, 5 Sep 2026 10:31:24 +0000
Subject: [PATCH 5/7] fix: keep Termwind HTML attributes through mixed tree
 slots

Store attributes as indexed [name, value] pairs so class and siblings
survive mixed token/tree storage, and type the prelude helpers as mixed.

Co-authored-by: Vincenzo Petrucci 
---
 src/dom_html_prelude/surface.rs | 171 +++++++++++++++++++-------------
 tests/codegen/dom_html.rs       |  14 +++
 2 files changed, 115 insertions(+), 70 deletions(-)

diff --git a/src/dom_html_prelude/surface.rs b/src/dom_html_prelude/surface.rs
index ed119ebb18..af7c448d78 100644
--- a/src/dom_html_prelude/surface.rs
+++ b/src/dom_html_prelude/surface.rs
@@ -12,6 +12,9 @@
 //!   Draft PR #654 (`crates/elephc-dom`, libxml2 + Lexbor) remains the path
 //!   to full PHP 8.5 DOM. Same class names and `LIBXML_*` integers so that
 //!   work can replace this prelude without changing Termwind call sites.
+//! - Attribute maps are stored as indexed `[name, value]` pairs. Nested
+//!   string-keyed arrays lose their keys when they pass through mixed
+//!   token/tree slots, which dropped `class` on Termwind's styled `div`.
 //! - Written as PHP (not `synthetic_class` builders) because the HTML walk
 //!   is a few hundred lines of straightforward string/stack code; mysqli
 //!   and curl still use the same delivery form.
@@ -61,7 +64,7 @@ function __elephc_dom_skip_ws(string $html, int $i): int {
     return $i;
 }
 
-function __elephc_dom_read_name(string $html, int $i): array {
+function __elephc_dom_read_name(string $html, int $i): mixed {
     $_start = $i;
     $_len = strlen($html);
     while ($i < $_len && __elephc_dom_is_name(__elephc_dom_char($html, $i))) {
@@ -70,7 +73,7 @@ function __elephc_dom_read_name(string $html, int $i): array {
     return [strtolower(substr($html, $_start, $i - $_start)), $i];
 }
 
-function __elephc_dom_read_attr_value(string $html, int $i): array {
+function __elephc_dom_read_attr_value(string $html, int $i): mixed {
     $_len = strlen($html);
     $i = __elephc_dom_skip_ws($html, $i);
     $_q = __elephc_dom_char($html, $i);
@@ -97,7 +100,7 @@ function __elephc_dom_read_attr_value(string $html, int $i): array {
     return [html_entity_decode(substr($html, $_start, $i - $_start)), $i];
 }
 
-function __elephc_dom_read_attrs(string $html, int $i): array {
+function __elephc_dom_read_attrs(string $html, int $i): mixed {
     $_attrs = [];
     $_self = 0;
     $_len = strlen($html);
@@ -120,8 +123,8 @@ function __elephc_dom_read_attrs(string $html, int $i): array {
             break;
         }
         $_pair = __elephc_dom_read_name($html, $i);
-        $_name = $_pair[0];
-        $i = $_pair[1];
+        $_name = (string) $_pair[0];
+        $i = (int) $_pair[1];
         if ($_name === "") {
             $i = $i + 1;
             continue;
@@ -131,15 +134,30 @@ function __elephc_dom_read_attrs(string $html, int $i): array {
         if (__elephc_dom_char($html, $i) === "=") {
             $i = $i + 1;
             $_av = __elephc_dom_read_attr_value($html, $i);
-            $_val = $_av[0];
-            $i = $_av[1];
+            $_val = (string) $_av[0];
+            $i = (int) $_av[1];
         }
-        $_attrs[$_name] = $_val;
+        // Indexed [name, value] pairs: nested string-keyed maps lose their
+        // keys when stored through mixed tree/token slots.
+        $_attrs[] = [$_name, $_val];
     }
     return [$_attrs, $_self, $i];
 }
 
-function __elephc_dom_tokenize(string $html, int $flags): array {
+function __elephc_dom_attr_get(mixed $attrs, string $name): string {
+    if (!is_array($attrs)) {
+        return "";
+    }
+    $_key = strtolower($name);
+    foreach ($attrs as $_pair) {
+        if (is_array($_pair) && (string) $_pair[0] === $_key) {
+            return (string) $_pair[1];
+        }
+    }
+    return "";
+}
+
+function __elephc_dom_tokenize(string $html, int $flags): mixed {
     $_tokens = [];
     $_i = 0;
     $_len = strlen($html);
@@ -162,51 +180,51 @@ function __elephc_dom_tokenize(string $html, int $flags): array {
         }
         if (substr($html, $_i, 4) === "", $_i);
-            if ($_end === false) {
+            $_comment_end = strpos($html, "-->", $_i);
+            if ($_comment_end === false) {
                 $_body = substr($html, $_i);
                 $_i = $_len;
             } else {
-                $_body = substr($html, $_i, $_end - $_i);
-                $_i = $_end + 3;
+                $_body = substr($html, $_i, (int) $_comment_end - $_i);
+                $_i = (int) $_comment_end + 3;
             }
             $_tokens[] = ["k" => "comment", "v" => $_body];
             continue;
         }
         if (substr($html, $_i, 2) === "", $_i);
-            if ($_gt === false) {
+            $_decl_end = strpos($html, ">", $_i);
+            if ($_decl_end === false) {
                 break;
             }
-            $_i = $_gt + 1;
+            $_i = (int) $_decl_end + 1;
             continue;
         }
         if (substr($html, $_i, 2) === "", $_i);
-            if ($_gt === false) {
+        $_pair = __elephc_dom_read_name($html, $_i);
+        $_name = (string) $_pair[0];
+        $_i = (int) $_pair[1];
+        $_close_end = strpos($html, ">", $_i);
+            if ($_close_end === false) {
                 $_i = $_len;
             } else {
-                $_i = $_gt + 1;
+                $_i = (int) $_close_end + 1;
             }
             $_tokens[] = ["k" => "end", "n" => $_name];
             continue;
         }
         $_i = $_i + 1;
         $_pair = __elephc_dom_read_name($html, $_i);
-        $_name = $_pair[0];
-        $_i = $_pair[1];
+        $_name = (string) $_pair[0];
+        $_i = (int) $_pair[1];
         if ($_name === "") {
             $_tokens[] = ["k" => "text", "v" => "<"];
             continue;
         }
         $_ap = __elephc_dom_read_attrs($html, $_i);
         $_attrs = $_ap[0];
-        $_self = $_ap[1];
-        $_i = $_ap[2];
+        $_self = (int) $_ap[1];
+        $_i = (int) $_ap[2];
         if ($_self === 1 || __elephc_dom_is_void($_name)) {
             $_self = 1;
         }
@@ -215,7 +233,7 @@ function __elephc_dom_tokenize(string $html, int $flags): array {
     return $_tokens;
 }
 
-function __elephc_dom_text_of(array $node): string {
+function __elephc_dom_text_of(mixed $node): string {
     if ($node["kind"] === "text" || $node["kind"] === "comment") {
         return (string) $node["value"];
     }
@@ -226,7 +244,7 @@ function __elephc_dom_text_of(array $node): string {
     return $_out;
 }
 
-function __elephc_dom_new_node(string $kind, string $name, string $value, array $attrs, array $children): array {
+function __elephc_dom_new_node(string $kind, string $name, string $value, mixed $attrs, mixed $children): mixed {
     return [
         "kind" => $kind,
         "name" => $name,
@@ -236,7 +254,7 @@ function __elephc_dom_new_node(string $kind, string $name, string $value, array
     ];
 }
 
-function __elephc_dom_append_child(array $stack, array $node): array {
+function __elephc_dom_append_child(array $stack, mixed $node): array {
     $_top = $stack[count($stack) - 1];
     $_children = $_top["children"];
     $_children[] = $node;
@@ -245,7 +263,7 @@ function __elephc_dom_append_child(array $stack, array $node): array {
     return $stack;
 }
 
-function __elephc_dom_build_forest(array $tokens): array {
+function __elephc_dom_build_forest(mixed $tokens): mixed {
     $_stack = [];
     $_roots = [];
     foreach ($tokens as $_tok) {
@@ -298,7 +316,7 @@ function __elephc_dom_build_forest(array $tokens): array {
     return $_roots;
 }
 
-function __elephc_dom_find_child(array $node, string $name): mixed {
+function __elephc_dom_find_child(mixed $node, string $name): mixed {
     foreach ($node["children"] as $_child) {
         if ($_child["kind"] === "element" && $_child["name"] === $name) {
             return $_child;
@@ -307,7 +325,7 @@ function __elephc_dom_find_child(array $node, string $name): mixed {
     return null;
 }
 
-function __elephc_dom_wrap_html(array $roots): array {
+function __elephc_dom_wrap_html(mixed $roots): mixed {
     if (count($roots) === 1 && $roots[0]["kind"] === "element" && $roots[0]["name"] === "html") {
         $_html = $roots[0];
         if (__elephc_dom_find_child($_html, "body") === null) {
@@ -322,7 +340,7 @@ function __elephc_dom_wrap_html(array $roots): array {
     return __elephc_dom_new_node("element", "html", "", [], [$_body]);
 }
 
-function __elephc_dom_parse_html(string $html, int $flags): array {
+function __elephc_dom_parse_html(string $html, int $flags): mixed {
     $_tokens = __elephc_dom_tokenize($html, $flags);
     $_roots = __elephc_dom_build_forest($_tokens);
     $_html = __elephc_dom_wrap_html($_roots);
@@ -351,8 +369,13 @@ function __elephc_dom_serialize_node(mixed $node): string {
         return $_out;
     }
     $_out = "<" . $node->nodeName;
-    foreach ($node->__attrs as $_k => $_v) {
-        $_out = $_out . " " . $_k . "=\"" . __elephc_dom_escape((string) $_v) . "\"";
+    $_attrs = $node->__attrs;
+    if (is_array($_attrs)) {
+        foreach ($_attrs as $_pair) {
+            if (is_array($_pair)) {
+                $_out = $_out . " " . (string) $_pair[0] . "=\"" . __elephc_dom_escape((string) $_pair[1]) . "\"";
+            }
+        }
     }
     $_inner = "";
     if ($node->childNodes !== null) {
@@ -366,7 +389,7 @@ function __elephc_dom_serialize_node(mixed $node): string {
     return $_out . ">" . $_inner . "nodeName . ">";
 }
 
-function __elephc_dom_wire(array $siblings, mixed $parent): void {
+function __elephc_dom_wire(mixed $siblings, mixed $parent): void {
     $_n = count($siblings);
     $_doc = $parent;
     if (!($parent instanceof DOMDocument)) {
@@ -389,36 +412,36 @@ function __elephc_dom_wire(array $siblings, mixed $parent): void {
     }
 }
 
-function __elephc_dom_make_node(DOMDocument $doc, array $tree): mixed {
+function __elephc_dom_make_node(DOMDocument $doc, mixed $tree): mixed {
     $_kind = (string) $tree["kind"];
     if ($_kind === "text") {
-        $_n = new DOMText();
-        $_n->nodeName = "#text";
-        $_n->nodeValue = (string) $tree["value"];
-        $_n->ownerDocument = $doc;
-        $_n->childNodes = new DOMNodeList([]);
-        return $_n;
+        $_text = new DOMText();
+        $_text->nodeName = "#text";
+        $_text->nodeValue = (string) $tree["value"];
+        $_text->ownerDocument = $doc;
+        $_text->childNodes = new DOMNodeList([]);
+        return $_text;
     }
     if ($_kind === "comment") {
-        $_n = new DOMComment();
-        $_n->nodeName = "#comment";
-        $_n->nodeValue = (string) $tree["value"];
-        $_n->ownerDocument = $doc;
-        $_n->childNodes = new DOMNodeList([]);
-        return $_n;
-    }
-    $_n = new DOMElement();
-    $_n->nodeName = (string) $tree["name"];
-    $_n->__attrs = $tree["attrs"];
-    $_n->ownerDocument = $doc;
+        $_comment = new DOMComment();
+        $_comment->nodeName = "#comment";
+        $_comment->nodeValue = (string) $tree["value"];
+        $_comment->ownerDocument = $doc;
+        $_comment->childNodes = new DOMNodeList([]);
+        return $_comment;
+    }
+    $_el = new DOMElement();
+    $_el->nodeName = (string) $tree["name"];
+    $_el->__attrs = $tree["attrs"];
+    $_el->ownerDocument = $doc;
     $_kids = [];
     foreach ($tree["children"] as $_child) {
         $_kids[] = __elephc_dom_make_node($doc, $_child);
     }
-    $_n->childNodes = new DOMNodeList($_kids);
-    __elephc_dom_wire($_kids, $_n);
-    $_n->nodeValue = __elephc_dom_text_of($tree);
-    return $_n;
+    $_el->childNodes = new DOMNodeList($_kids);
+    __elephc_dom_wire($_kids, $_el);
+    $_el->nodeValue = __elephc_dom_text_of($tree);
+    return $_el;
 }
 
 function __elephc_dom_collect(mixed $node, string $name): array {
@@ -448,14 +471,10 @@ class DOMNode {
     public mixed $nextSibling = null;
     public mixed $parentNode = null;
     public mixed $ownerDocument = null;
-    public array $__attrs = [];
+    public mixed $__attrs = [];
 
     public function getAttribute(string $name): string {
-        $_key = strtolower($name);
-        if (array_key_exists($_key, $this->__attrs)) {
-            return (string) $this->__attrs[$_key];
-        }
-        return "";
+        return __elephc_dom_attr_get($this->__attrs, $name);
     }
 
     public function getElementsByTagName(string $qualifiedName): DOMNodeList {
@@ -465,12 +484,16 @@ class DOMNode {
 
 class DOMNodeList implements Iterator {
     public int $length = 0;
-    public array $__items = [];
+    public mixed $__items = [];
     private int $__i = 0;
 
-    public function __construct(array $items = []) {
-        $this->__items = $items;
-        $this->length = count($items);
+    public function __construct(mixed $items = []) {
+        $_list = [];
+        if (is_array($items)) {
+            $_list = $items;
+        }
+        $this->__items = $_list;
+        $this->length = count($_list);
         $this->__i = 0;
     }
 
@@ -478,7 +501,11 @@ class DOMNodeList implements Iterator {
         if ($index < 0 || $index >= $this->length) {
             return null;
         }
-        return $this->__items[$index];
+        $_items = $this->__items;
+        if (!is_array($_items)) {
+            return null;
+        }
+        return $_items[$index];
     }
 
     public function rewind(): void {
@@ -490,7 +517,11 @@ class DOMNodeList implements Iterator {
     }
 
     public function current(): mixed {
-        return $this->__items[$this->__i];
+        $_items = $this->__items;
+        if (!is_array($_items)) {
+            return null;
+        }
+        return $_items[$this->__i];
     }
 
     public function key(): mixed {
diff --git a/tests/codegen/dom_html.rs b/tests/codegen/dom_html.rs
index eeba554355..12fb288ebc 100644
--- a/tests/codegen/dom_html.rs
+++ b/tests/codegen/dom_html.rs
@@ -132,6 +132,20 @@ echo $div->ownerDocument->saveXML($div);
     assert_eq!(out, "D:
Hi
"); } +/// Verifies several attributes on one element stay addressable by name. +#[test] +fn test_multiple_attributes() { + let out = compile_and_run( + r#"loadHTML('', LIBXML_NOERROR | LIBXML_NOBLANKS); +$div = $dom->getElementsByTagName('div')->item(0); +echo $div->getAttribute('class'), '|', $div->getAttribute('id'), '|', $div->getAttribute('hidden'), '|', $div->getAttribute('missing'); +"#, + ); + assert_eq!(out, "text-green-500|hero||"); +} + /// Verifies namespaced unqualified `LIBXML_*` and `\DOMDocument` as Termwind spells them. #[test] fn test_namespaced_termwind_call_shape() { From 4d9745bb7b04c4f19c9b43549fea327a76b58043 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 10:36:46 +0000 Subject: [PATCH 6/7] fix: wire DOM siblings through typed node handles Assign nextSibling/previousSibling on instanceof-narrowed DOMNode locals. Mixed array element writes copy the object and drop the links Termwind's Node walk reads. Co-authored-by: Vincenzo Petrucci --- src/dom_html_prelude/surface.rs | 89 ++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/src/dom_html_prelude/surface.rs b/src/dom_html_prelude/surface.rs index af7c448d78..f3a37f18a4 100644 --- a/src/dom_html_prelude/surface.rs +++ b/src/dom_html_prelude/surface.rs @@ -15,6 +15,9 @@ //! - Attribute maps are stored as indexed `[name, value]` pairs. Nested //! string-keyed arrays lose their keys when they pass through mixed //! token/tree slots, which dropped `class` on Termwind's styled `div`. +//! - Sibling pointers are assigned through typed `DOMNode` parameters on +//! `instanceof`-narrowed locals, then those locals are stored. Writes on +//! `mixed` array elements copy the object and drop `nextSibling`. //! - Written as PHP (not `synthetic_class` builders) because the HTML walk //! is a few hundred lines of straightforward string/stack code; mysqli //! and curl still use the same delivery form. @@ -389,27 +392,65 @@ function __elephc_dom_serialize_node(mixed $node): string { return $_out . ">" . $_inner . "nodeName . ">"; } -function __elephc_dom_wire(mixed $siblings, mixed $parent): void { - $_n = count($siblings); - $_doc = $parent; - if (!($parent instanceof DOMDocument)) { - $_doc = $parent->ownerDocument; +function __elephc_dom_link_siblings(DOMNode $left, DOMNode $right): void { + $left->nextSibling = $right; + $right->previousSibling = $left; +} + +function __elephc_dom_attach_parent(DOMNode $node, DOMNode $parent, mixed $doc): void { + $node->parentNode = $parent; + $node->ownerDocument = $doc; +} + +function __elephc_dom_link_prev(int $has_prev, int $prev_kind, DOMElement $prev_el, DOMText $prev_text, DOMComment $prev_comment, DOMNode $right): void { + if ($has_prev !== 1) { + return; } - for ($_i = 0; $_i < $_n; $_i++) { - $_node = $siblings[$_i]; - $_node->parentNode = $parent; - $_node->ownerDocument = $_doc; - if ($_i > 0) { - $_node->previousSibling = $siblings[$_i - 1]; - } else { - $_node->previousSibling = null; - } - if ($_i + 1 < $_n) { - $_node->nextSibling = $siblings[$_i + 1]; - } else { - $_node->nextSibling = null; + if ($prev_kind === 1) { + __elephc_dom_link_siblings($prev_el, $right); + } elseif ($prev_kind === 2) { + __elephc_dom_link_siblings($prev_text, $right); + } else { + __elephc_dom_link_siblings($prev_comment, $right); + } +} + +function __elephc_dom_make_children(DOMDocument $doc, DOMNode $parent, mixed $trees): mixed { + $_kids = []; + $_has_prev = 0; + $_prev_kind = 0; + $_prev_el = new DOMElement(); + $_prev_text = new DOMText(); + $_prev_comment = new DOMComment(); + foreach ($trees as $_child) { + $_kid = __elephc_dom_make_node($doc, $_child); + if ($_kid instanceof DOMElement) { + $_el_kid = $_kid; + __elephc_dom_attach_parent($_el_kid, $parent, $doc); + __elephc_dom_link_prev($_has_prev, $_prev_kind, $_prev_el, $_prev_text, $_prev_comment, $_el_kid); + $_kids[] = $_el_kid; + $_prev_el = $_el_kid; + $_prev_kind = 1; + $_has_prev = 1; + } elseif ($_kid instanceof DOMText) { + $_text_kid = $_kid; + __elephc_dom_attach_parent($_text_kid, $parent, $doc); + __elephc_dom_link_prev($_has_prev, $_prev_kind, $_prev_el, $_prev_text, $_prev_comment, $_text_kid); + $_kids[] = $_text_kid; + $_prev_text = $_text_kid; + $_prev_kind = 2; + $_has_prev = 1; + } elseif ($_kid instanceof DOMComment) { + $_comment_kid = $_kid; + __elephc_dom_attach_parent($_comment_kid, $parent, $doc); + __elephc_dom_link_prev($_has_prev, $_prev_kind, $_prev_el, $_prev_text, $_prev_comment, $_comment_kid); + $_kids[] = $_comment_kid; + $_prev_comment = $_comment_kid; + $_prev_kind = 3; + $_has_prev = 1; } } + return $_kids; } function __elephc_dom_make_node(DOMDocument $doc, mixed $tree): mixed { @@ -434,12 +475,8 @@ function __elephc_dom_make_node(DOMDocument $doc, mixed $tree): mixed { $_el->nodeName = (string) $tree["name"]; $_el->__attrs = $tree["attrs"]; $_el->ownerDocument = $doc; - $_kids = []; - foreach ($tree["children"] as $_child) { - $_kids[] = __elephc_dom_make_node($doc, $_child); - } + $_kids = __elephc_dom_make_children($doc, $_el, $tree["children"]); $_el->childNodes = new DOMNodeList($_kids); - __elephc_dom_wire($_kids, $_el); $_el->nodeValue = __elephc_dom_text_of($tree); return $_el; } @@ -550,12 +587,8 @@ class DOMDocument extends DOMNode { $_tree = __elephc_dom_parse_html($source, $options); $this->nodeName = "#document"; $this->ownerDocument = $this; - $_kids = []; - foreach ($_tree["children"] as $_child) { - $_kids[] = __elephc_dom_make_node($this, $_child); - } + $_kids = __elephc_dom_make_children($this, $this, $_tree["children"]); $this->childNodes = new DOMNodeList($_kids); - __elephc_dom_wire($_kids, $this); $this->nodeValue = (string) $_tree["value"]; return true; } From 45a2fe5f8e1740a07c57c238c4ceda493d27b9be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 14:41:10 +0000 Subject: [PATCH 7/7] fix: inject DOM HTML prelude in EIR examples corpus The examples corpus type-checks examples/dom-html without the pay-for-use prelude that pipeline.rs injects, so DOMDocument was undefined. Co-authored-by: Vincenzo Petrucci --- src/ir_lower/tests/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ir_lower/tests/mod.rs b/src/ir_lower/tests/mod.rs index bec0e72d03..1564fbfc64 100644 --- a/src/ir_lower/tests/mod.rs +++ b/src/ir_lower/tests/mod.rs @@ -70,6 +70,9 @@ fn lower_source_at(source: &str, main_file_path: &Path, parent: &Path) -> crate: let ast = crate::image_prelude::inject_if_used(ast, false, &mut prelude_inventory); let ast = crate::hash_prelude::inject_if_used(ast, false, &mut prelude_inventory); let ast = crate::curl_prelude::inject_if_used(ast, false, &mut prelude_inventory); + // Same order as `pipeline::compile`: Termwind DOM HTML after curl so + // `examples/dom-html` type-checks in the examples corpus. + let ast = crate::dom_html_prelude::inject_if_used(ast, false, &mut prelude_inventory); let ast = crate::name_resolver::resolve(ast).expect("name resolution failed"); let (ast, _) = crate::autoload::run_collecting_included_with_defines( ast,