Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<crate>` 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.
Expand Down
3 changes: 2 additions & 1 deletion crates/elephc-magician/src/interpreter/constant_eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}
Expand Down
18 changes: 18 additions & 0 deletions crates/elephc-magician/src/interpreter/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
77 changes: 77 additions & 0 deletions docs/php/dom-html.md
Original file line number Diff line number Diff line change
@@ -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 `<div class="text-green-500">Hi</div>`: 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
<?php
$dom = new DOMDocument();
$dom->loadHTML(
'<div class="text-green-500">Hi</div>',
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 `<?xml …?>`. |
| `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 `<head>` insertion are unimplemented.
- Specialized Termwind renderers that need richer markup (`<table>`, `<code>`, `<pre>` 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.
2 changes: 2 additions & 0 deletions docs/php/eval.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/dom-html/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.s
*.o
main
17 changes: 17 additions & 0 deletions examples/dom-html/main.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
$dom = new DOMDocument();
$html = '<div class="text-green-500">Hi</div>';
$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";
}
7 changes: 7 additions & 0 deletions src/codegen_support/prescan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
109 changes: 109 additions & 0 deletions src/dom_html_prelude.rs
Original file line number Diff line number Diff line change
@@ -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<Program> = OnceLock::new();

/// Tokenizes and parses the DOM HTML prelude exactly once.
fn parsed_prelude() -> Program {
PARSED_PRELUDE
.get_or_init(|| {
let source = format!("<?php\n{}", surface::SRC);
let tokens = crate::lexer::tokenize(&source).expect("dom html prelude must tokenize");
crate::parser::parse_internal(&tokens).expect("dom html prelude must parse")
})
.clone()
}

/// Prepends the Termwind DOM HTML prelude when the program names a DOM class.
///
/// `force` exists for the codegen harness and future opt-in; ordinary compiles
/// pass `false` and rely on `program_uses_dom_html`.
pub fn inject_if_used(
program: Program,
force: bool,
inventory: &mut crate::optimize::reachability::PreludeInventory,
) -> Program {
if !force && !detect::program_uses_dom_html(&program) {
return program;
Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 User DOM classes collide

When a program declares and uses its own global DOMDocument or another supported DOM class, inject_if_used prepends the same class declarations without checking the program’s declarations, causing type checking to fail with a duplicate-class error.

Context Used: AGENTS.md (source)

Knowledge Base Used: Frontend parsing and symbol resolution

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/dom_html_prelude.rs
Line: 52-54

Comment:
**User DOM classes collide**

When a program declares and uses its own global `DOMDocument` or another supported DOM class, `inject_if_used` prepends the same class declarations without checking the program’s declarations, causing type checking to fail with a duplicate-class error.

**Context Used:** AGENTS.md ([source](https://github.com/illegalstudio/elephc/blob/main/AGENTS.md))

**Knowledge Base Used:** [Frontend parsing and symbol resolution](https://app.greptile.com/illegal-studio/-/custom-context/knowledge-base/illegalstudio/elephc/-/docs/frontend-parsing-and-resolution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

}
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<String> = 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");
}
}
Loading
Loading