A fast, fault-tolerant PHP parser written in Rust. Produces a full typed AST with source spans, recovers from syntax errors, and covers PHP 7.4–8.5 syntax.
Try the interactive playground → · AST Node Reference →
[dependencies]
php-rs-parser = "*"
php-ast = "*" # AST types and Visitor trait
bumpalo = "*" # arena allocator
# Optional
php-printer = "*" # pretty-print AST back to PHP sourceuse php_rs_parser::parse;
let arena = bumpalo::Bump::new();
let result = parse(&arena, "<?php echo 'Hello, world!';");
println!("{:#?}", result.program);
for err in &result.errors {
println!("error at {:?}: {}", err.span(), err);
}
// Resolve byte offsets to line/column
let pos = result.source_map.offset_to_line_col(6);Every AST node is bump-allocated into bumpalo::Bump. The 'arena lifetime on ParseResult borrows from the arena, so the arena must outlive any reference into the AST. Drop the arena to free all nodes at once:
let arena = bumpalo::Bump::new();
let result = parse(&arena, "<?php $x = 1;");
// use result...
drop(result);
drop(arena); // frees all AST memory in one shotparse()/parse_versioned()— main parser entry points; seedocs.rs/php-rs-parserVisitor/ScopeVisitor— AST traversal traits; seedocs.rs/php-astfor the visitor infrastructureParseErrorvariants — seecrates/php-parser/src/diagnostics.rsfor all variants and recovery behavior- AST node types — see
docs.rs/php-ast/astfor the full set of statement, expression, and declaration nodes
The parser targets PHP 8.5 by default. Use parse_versioned() to target an earlier version:
use php_rs_parser::{parse_versioned, PhpVersion};
let arena = bumpalo::Bump::new();
let result = parse_versioned(
&arena,
"<?php enum Status { case Active; }",
PhpVersion::Php80,
);
// Enums require PHP 8.1 — a VersionTooLow diagnostic is emitted.
assert!(!result.errors.is_empty());Supported versions: Php74, Php80, Php81, Php82, Php83, Php84, Php85.
| Field | Type | Description |
|---|---|---|
program |
Program |
The parsed AST. Always present, even when errors exist. |
errors |
Vec<ParseError> |
Parse errors and diagnostics. Empty on success. |
errors_truncated |
bool |
true when the error list was capped. Treat the result as incomplete (relevant for linters). |
source |
&str |
The original source text. Slice spans directly: &result.source[span.start as usize..span.end as usize]. |
comments |
Vec<Comment> |
All comments in source order. Comments are not attached to AST nodes — map them to adjacent nodes by comparing spans. |
source_map |
SourceMap |
Pre-computed line index. Use offset_to_line_col(offset) to convert byte offsets to (line, col). |
The parser never fails — it always produces a complete AST. When it cannot parse a statement, it emits a ParseError and inserts a StmtKind::Error node as a placeholder so the tree is structurally intact:
let arena = bumpalo::Bump::new();
let result = parse(&arena, "<?php function f() { $ }");
assert!(!result.errors.is_empty()); // parse error reported
assert!(!result.program.stmts.is_empty()); // AST still produced
// result.program.stmts contains a FunctionDecl whose body has a StmtKind::Error nodeUse ParserContext when parsing the same document repeatedly (e.g. on every keystroke). It reuses the backing arena memory in O(1), avoiding allocator churn:
let mut ctx = php_rs_parser::ParserContext::new();
let result = ctx.reparse("<?php echo 1;");
assert!(result.errors.is_empty());
drop(result); // must be dropped before the next reparse
let result = ctx.reparse("<?php echo 2;");
assert!(result.errors.is_empty());reparse_versioned is also available for targeting a specific PHP version.
Implement Visitor to walk the AST depth-first. Override only the node types you care about; the default implementations recurse into children automatically.
use php_ast::visitor::{Visitor, walk_expr};
use php_ast::ast::*;
use std::ops::ControlFlow;
struct VarCounter { count: usize }
impl<'arena, 'src> Visitor<'arena, 'src> for VarCounter {
fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
if matches!(&expr.kind, ExprKind::Variable(_)) {
self.count += 1;
}
walk_expr(self, expr)
}
}Return ControlFlow::Break(()) to stop traversal early. Return ControlFlow::Continue(()) without calling walk_* to skip a subtree.
Use ScopeVisitor + ScopeWalker when your visitor needs to know which namespace, class, or function it is currently inside. Every visit method receives a Scope with that context:
use php_ast::visitor::{ScopeVisitor, ScopeWalker, Scope};
use php_ast::ast::*;
use std::ops::ControlFlow;
struct MethodCollector { methods: Vec<String> }
impl<'arena, 'src> ScopeVisitor<'arena, 'src> for MethodCollector {
fn visit_class_member(
&mut self,
member: &ClassMember<'arena, 'src>,
scope: &Scope<'src>,
) -> ControlFlow<()> {
if let ClassMemberKind::Method(m) = &member.kind {
self.methods.push(format!(
"{}::{}",
scope.class_name.unwrap_or("<anon>"),
m.name
));
}
ControlFlow::Continue(())
}
}
let arena = bumpalo::Bump::new();
let result = parse(&arena, "<?php class Foo { function bar() {} }");
let mut walker = ScopeWalker::new(result.source, MethodCollector { methods: vec![] });
walker.walk(&result.program);
// walker.into_inner().methods == ["Foo::bar"]Use plain Visitor when you don't need namespace/class/function context.
Fold is the transformation counterpart of Visitor. Where Visitor reads a tree in place, Fold rebuilds it — reading from an input arena and writing into a new output arena. This is the correct design for arena-allocated ASTs: in-place mutation would break the arena lifetime invariant.
Implement Fold and override only the node types you want to change. All other nodes are rebuilt identically by the default implementations:
use bumpalo::Bump;
use php_ast::fold::{Fold, fold_expr};
use php_ast::ast::*;
struct NegateInts;
impl<'src> Fold<'src> for NegateInts {
fn fold_expr<'new>(&mut self, arena: &'new Bump, expr: &Expr<'_, 'src>) -> Expr<'new, 'src> {
if let ExprKind::Int(n) = expr.kind {
return Expr { kind: ExprKind::Int(-n), span: expr.span };
}
fold_expr(self, arena, expr)
}
}
let src_arena = Bump::new();
let result = php_rs_parser::parse(&src_arena, "<?php $x = 1;");
let out_arena = Bump::new();
let transformed = NegateInts.fold_program(&out_arena, &result.program);
// `transformed` lives in `out_arena`; `result` and `src_arena` can be dropped independentlyPHPDoc comments are parsed into a structured AST via php_rs_parser::phpdoc::parse(). Tag bodies are exposed as raw text — the parser does not interpret type expressions, letting you apply your own type parser:
use php_rs_parser::phpdoc::{parse, find_tags, body_text};
let doc = parse("/** @param int $x The value\n * @return bool */");
for param in find_tags(&doc, "param") {
let body = body_text(¶m.body).unwrap_or_default();
println!("{}", body); // "int $x The value"
}let arena = bumpalo::Bump::new();
let result = php_rs_parser::parse(&arena, "<?php echo 1 + 2;");
let output = php_printer::pretty_print(&result.program);
// output == "echo 1 + 2;"Use pretty_print_file to produce a complete file with a <?php\n\n prefix and trailing newline.
To preserve comments in the output, use pretty_print_with_comments:
let output = php_printer::pretty_print_with_comments(
&result.program,
result.source,
&result.comments,
);To customise indentation or newlines, pass a PrinterConfig:
use php_printer::{PrinterConfig, Indent};
let config = PrinterConfig { indent: Indent::Spaces(2), ..Default::default() };
let output = php_printer::pretty_print_with_config(&result.program, &config);Four crates, one workspace:
Source flows through Lexer → Parser → arena-allocated AST nodes. The lexer is lazy (tokens produced on demand with peeking slots); the parser is Pratt-based recursive descent with panic-mode error recovery.
The fastest full-featured PHP parser. Optimised for modern PHP applications with full typing (PHP 7.4+, 8.x). For comparative benchmarks against other PHP parsers see php-parser-benchmark.
See CONTRIBUTING.md for build instructions, testing, and contributor guides.
Inspired by and indebted to nikic/PHP-Parser — test corpus fixtures were adapted from its test suite. Thanks to the PHP community contributors.
BSD 3-Clause