php_rs_parser/lib.rs
1//! Fast, fault-tolerant PHP parser that produces a fully typed AST.
2//!
3//! This crate parses PHP source code (PHP 7.4–8.5, plus PHP 8.6 partial function
4//! application) into a [`php_ast::Program`] tree, recovering from syntax errors
5//! so that downstream tools always receive a complete AST.
6//!
7//! # Semantic-rejection responsibility
8//!
9//! The parser is fault-tolerant: it always produces an AST and reports every
10//! error it can identify before recovering. Its semantic-rejection
11//! responsibility is defined externally:
12//!
13//! > **For any input, the parser emits at least one diagnostic iff `php -l`
14//! > would reject that input at the configured target PHP version.**
15//!
16//! Flow-sensitive checks — cross-file resolution, unused variables, dead code,
17//! type-mismatched returns — are out of scope and belong in a later semantic
18//! layer. Checks decidable from one declaration, one parameter list, one
19//! modifier set, or one declaration loop are in scope and use
20//! [`diagnostics::ParseError::Forbidden`].
21//!
22//! The `===php_error===` section in `tests/fixtures/**/*.phpt` records `php -l`
23//! output; the fixture runner enforces the rule above by failing CI when PHP
24//! rejects an input that the parser silently accepts.
25//!
26//! # Quick start
27//!
28//! ```
29//! let result = php_rs_parser::parse("<?php echo 'hello';");
30//! assert!(result.errors.is_empty());
31//! ```
32//!
33//! # Version-aware parsing
34//!
35//! Use [`parse_with`] and [`ParseOptions`] to target a specific PHP version.
36//! Syntax that requires a higher version is still parsed into the AST, but a
37//! [`diagnostics::ParseError::VersionTooLow`] diagnostic is emitted.
38//!
39//! ```
40//! use php_rs_parser::{ParseOptions, PhpVersion};
41//!
42//! let result = php_rs_parser::parse_with(
43//! "<?php enum Status { case Active; }",
44//! &ParseOptions::default().version(PhpVersion::Php80),
45//! );
46//! assert!(!result.errors.is_empty()); // enums require PHP 8.1
47//! ```
48//!
49//! # Multi-file cache
50//!
51//! [`parse`] returns a [`ParseResult`] with no lifetime parameters — fully
52//! owned, storable in a `HashMap`, sendable across threads.
53//!
54//! ```
55//! use std::collections::HashMap;
56//! use std::path::PathBuf;
57//!
58//! let mut cache: HashMap<PathBuf, php_rs_parser::ParseResult> = HashMap::new();
59//! cache.insert(PathBuf::from("a.php"), php_rs_parser::parse("<?php echo 1;"));
60//! ```
61//!
62//! # Arena API (LSP / hot-path usage)
63//!
64//! Use [`parse_arena`] / [`ParserContext`] when you need maximum throughput
65//! and can manage the arena lifetime yourself. The returned
66//! [`ArenaParseResult`] borrows from both the arena and the source string —
67//! no allocation copying occurs.
68//!
69//! ```
70//! let mut ctx = php_rs_parser::ParserContext::new();
71//!
72//! let result = ctx.reparse("<?php echo 1;");
73//! assert!(result.errors.is_empty());
74//! drop(result); // must be dropped before the next reparse
75//!
76//! let result = ctx.reparse("<?php echo 2;");
77//! assert!(result.errors.is_empty());
78//! ```
79//!
80//! Set [`ParseOptions::source_map`] to `false` when you never need
81//! line/column positions — it skips building the [`source_map::SourceMap`]
82//! and is slightly faster.
83
84#![warn(missing_docs)]
85
86#[cfg(doctest)]
87#[doc = include_str!("../../../README.md")]
88struct ReadmeDoctests;
89
90/// Parse errors and warnings.
91pub mod diagnostics;
92pub(crate) mod expr;
93/// Parser instrumentation counters, available with the `instrument` feature.
94#[cfg(feature = "instrument")]
95pub mod instrument;
96#[cfg(not(feature = "instrument"))]
97pub(crate) mod instrument;
98pub(crate) mod parser;
99/// The arena allocator used by [`parse_arena`] and [`ParserContext`],
100/// re-exported so callers use the same version as the AST types.
101pub use bumpalo;
102pub use phpdoc_parser as phpdoc;
103pub(crate) mod precedence;
104/// Byte offset to line/column mapping.
105pub mod source_map;
106pub(crate) mod stmt;
107/// Target PHP version selection.
108pub mod version;
109
110use diagnostics::ParseError;
111use php_ast::owned::Comment as OwnedComment;
112use php_ast::{owned::to_owned_program, Comment, Program};
113use source_map::SourceMap;
114pub use version::PhpVersion;
115
116/// Lifetime-free result of parsing a PHP source string.
117///
118/// This is the primary return type of [`parse`] and [`parse_with`]. The
119/// AST is fully owned (`Box<str>`, `Box<[T]>`) so it can be stored in a
120/// `HashMap`, sent across threads, or cached alongside other data without
121/// fighting the borrow checker.
122///
123/// Use [`parse_arena`] or [`ParserContext`] when you need the arena-allocated
124/// form for maximum throughput in tight loops or LSP re-parse scenarios.
125#[derive(Debug, Clone)]
126#[non_exhaustive]
127pub struct ParseResult {
128 /// The original source text, owned.
129 pub source: String,
130 /// The parsed AST, fully owned with no lifetime parameters.
131 pub program: php_ast::owned::Program,
132 /// All comments found in the source, in source order. Doc-block comments
133 /// attached to a declaration are stored in the declaration node's
134 /// `doc_comment` field, not here.
135 pub comments: Vec<php_ast::owned::Comment>,
136 /// Parse errors and diagnostics. Empty on a successful parse.
137 pub errors: Vec<ParseError>,
138 /// `true` when the error list was capped and further errors were dropped.
139 pub errors_truncated: bool,
140 /// Pre-computed line index for span-to-line/column resolution.
141 pub source_map: SourceMap,
142}
143
144impl ParseResult {
145 fn from_arena_result(result: ArenaParseResult<'_, '_>) -> Self {
146 let program = to_owned_program(&result.program);
147 let comments = result
148 .comments
149 .iter()
150 .map(|c| OwnedComment {
151 kind: c.kind,
152 text: c.text.into(),
153 span: c.span,
154 })
155 .collect();
156 Self {
157 source: result.source.to_owned(),
158 program,
159 comments,
160 errors: result.errors,
161 errors_truncated: result.errors_truncated,
162 source_map: result.source_map,
163 }
164 }
165}
166
167/// Arena-allocated result of parsing a PHP source string.
168///
169/// Returned by [`parse_arena`], [`parse_arena_with`], and
170/// [`ParserContext::reparse`]. Both the AST and the source text are borrowed,
171/// so this type has two lifetime parameters. Use [`ParseResult`] (from
172/// [`parse`]) when you need an owned, lifetime-free result.
173#[derive(Debug)]
174#[non_exhaustive]
175pub struct ArenaParseResult<'arena, 'src> {
176 /// The original source text. Useful for extracting text from spans
177 /// via `&result.source[span.start as usize..span.end as usize]`.
178 pub source: &'src str,
179 /// The parsed AST. Always produced, even when errors are present.
180 pub program: Program<'arena, 'src>,
181 /// All comments found in the source, in source order, **except** `/** */`
182 /// doc-block comments that are immediately attached to a declaration.
183 ///
184 /// When the parser encounters a `/** */` comment directly before a
185 /// function, class, method, property, constant, or enum case, it removes
186 /// that comment from this list and stores it in the declaration node's
187 /// `doc_comment` field instead. The two collections are therefore
188 /// **disjoint**: iterating both without deduplication will double-count
189 /// nothing, but iterating only one will miss the other's entries.
190 ///
191 /// To process every comment in the file, iterate `result.comments` (for
192 /// line, hash, block, and unattached doc comments) and also visit each
193 /// declaration node's `doc_comment` field. Or use
194 /// [`php_ast::visitor::walk_comments`] with a [`Visitor`](php_ast::visitor::Visitor) that also
195 /// overrides the declaration visit methods.
196 pub comments: Vec<Comment<'src>>,
197 /// Parse errors and diagnostics. Empty on a successful parse.
198 pub errors: Vec<ParseError>,
199 /// `true` when the error list was capped at the internal limit and further
200 /// errors were silently dropped. Callers that need a complete error list
201 /// (e.g. linters) should treat this as an incomplete result.
202 pub errors_truncated: bool,
203 /// Pre-computed line index for resolving byte offsets in [`Span`](php_ast::Span)
204 /// to line/column positions. Use [`SourceMap::offset_to_line_col`] or
205 /// [`SourceMap::span_to_line_col`] to convert.
206 pub source_map: SourceMap,
207}
208
209/// Options for [`parse_with`], [`parse_arena_with`] and [`ParserContext::with_options`].
210///
211/// ```
212/// use php_rs_parser::{ParseOptions, PhpVersion};
213///
214/// let options = ParseOptions::default().version(PhpVersion::Php80);
215/// let result = php_rs_parser::parse_with("<?php enum Status {}", &options);
216/// assert!(!result.errors.is_empty()); // enums require PHP 8.1
217/// ```
218#[derive(Debug, Clone, PartialEq, Eq)]
219#[non_exhaustive]
220pub struct ParseOptions {
221 /// Target PHP version; newer syntax is parsed but reported as [`ParseError::VersionTooLow`].
222 pub version: PhpVersion,
223 /// Whether to build `result.source_map`; when `false` it is left empty. Defaults to `true`.
224 pub source_map: bool,
225}
226
227impl Default for ParseOptions {
228 fn default() -> Self {
229 Self {
230 version: PhpVersion::default(),
231 source_map: true,
232 }
233 }
234}
235
236impl ParseOptions {
237 /// Sets [`version`](Self::version).
238 pub fn version(mut self, version: PhpVersion) -> Self {
239 self.version = version;
240 self
241 }
242
243 /// Sets [`source_map`](Self::source_map).
244 pub fn source_map(mut self, source_map: bool) -> Self {
245 self.source_map = source_map;
246 self
247 }
248}
249
250/// Parse PHP `source` with the default [`ParseOptions`].
251///
252/// Returns a fully-owned [`ParseResult`] with no lifetime parameters. The
253/// internal arena is created, used, and converted within this call.
254///
255/// Use [`parse_arena`] when you need the raw arena-allocated AST for maximum
256/// throughput (no allocation copying).
257pub fn parse(source: &str) -> ParseResult {
258 parse_with(source, &ParseOptions::default())
259}
260
261/// Parse PHP `source` with the given `options`, returning a fully-owned [`ParseResult`].
262pub fn parse_with(source: &str, options: &ParseOptions) -> ParseResult {
263 let arena = bumpalo::Bump::new();
264 ParseResult::from_arena_result(parse_arena_with(&arena, source, options))
265}
266
267/// Parse PHP `source` with the default [`ParseOptions`], returning an
268/// arena-allocated [`ArenaParseResult`].
269///
270/// The `arena` is used for all AST allocations, giving callers control over
271/// memory lifetime. The returned result borrows from both the arena and the
272/// source string.
273///
274/// Prefer [`parse`] unless you are managing the arena yourself for performance
275/// reasons (e.g. LSP re-parsing with [`ParserContext`]).
276pub fn parse_arena<'arena, 'src>(
277 arena: &'arena bumpalo::Bump,
278 source: &'src str,
279) -> ArenaParseResult<'arena, 'src> {
280 parse_arena_with(arena, source, &ParseOptions::default())
281}
282
283/// Parse PHP `source` with the given `options`, returning an arena-allocated
284/// [`ArenaParseResult`].
285///
286/// Spans are `u32`, so a source over 4 GiB yields an empty program and [`ParseError::SourceTooLarge`].
287pub fn parse_arena_with<'arena, 'src>(
288 arena: &'arena bumpalo::Bump,
289 source: &'src str,
290 options: &ParseOptions,
291) -> ArenaParseResult<'arena, 'src> {
292 if source.len() > u32::MAX as usize {
293 return ArenaParseResult {
294 source,
295 program: Program {
296 stmts: php_ast::ArenaVec::new_in(arena),
297 span: php_ast::Span::new(0, 0),
298 },
299 comments: Vec::new(),
300 errors: vec![ParseError::SourceTooLarge {
301 len: source.len(),
302 span: php_ast::Span::new(0, 0),
303 }],
304 errors_truncated: false,
305 source_map: SourceMap::empty(),
306 };
307 }
308 let mut parser = parser::Parser::with_version(arena, source, options.version);
309 let program = parser.parse_program();
310 let errors_truncated = parser.errors_truncated();
311 ArenaParseResult {
312 source,
313 program,
314 comments: parser.take_comments(),
315 errors: parser.into_errors(),
316 errors_truncated,
317 source_map: if options.source_map {
318 SourceMap::new(source)
319 } else {
320 SourceMap::empty()
321 },
322 }
323}
324
325/// A reusable parse context that keeps a `bumpalo::Bump` arena alive between
326/// re-parses, resetting it (O(1)) instead of dropping and reallocating.
327///
328/// This is the preferred entry point for LSP servers or any tool that parses
329/// the same document repeatedly. Once the arena has grown to accommodate the
330/// largest document seen, subsequent parses reuse the backing memory without
331/// any new allocations.
332///
333/// The Rust lifetime system enforces safety: the returned [`ArenaParseResult`]
334/// borrows from `self`, so the borrow checker prevents calling
335/// [`reparse`](ParserContext::reparse) again while the previous result is
336/// still alive.
337///
338/// # Example
339///
340/// ```
341/// let mut ctx = php_rs_parser::ParserContext::new();
342///
343/// let result = ctx.reparse("<?php echo 1;");
344/// assert!(result.errors.is_empty());
345/// drop(result); // must be dropped before the next reparse
346///
347/// let result = ctx.reparse("<?php echo 2;");
348/// assert!(result.errors.is_empty());
349/// ```
350#[derive(Debug)]
351pub struct ParserContext {
352 arena: bumpalo::Bump,
353 options: ParseOptions,
354}
355
356impl ParserContext {
357 /// Create a new context with an empty arena and the default [`ParseOptions`].
358 pub fn new() -> Self {
359 Self::with_options(ParseOptions::default())
360 }
361
362 /// Create a new context with an empty arena that parses with `options`.
363 pub fn with_options(options: ParseOptions) -> Self {
364 Self {
365 arena: bumpalo::Bump::new(),
366 options,
367 }
368 }
369
370 /// The options used by every reparse.
371 pub fn options(&self) -> &ParseOptions {
372 &self.options
373 }
374
375 /// Replace the options used by later reparses, keeping the arena.
376 pub fn set_options(&mut self, options: ParseOptions) {
377 self.options = options;
378 }
379
380 /// Reset the arena and parse `source`.
381 ///
382 /// The previous [`ArenaParseResult`] **must be dropped** before calling
383 /// this method. The borrow checker enforces this: the returned result
384 /// borrows `self` for the duration of its lifetime, so a second call while
385 /// the first result is still live is a compile-time error.
386 pub fn reparse<'a, 'src>(&'a mut self, source: &'src str) -> ArenaParseResult<'a, 'src> {
387 self.arena.reset();
388 parse_arena_with(&self.arena, source, &self.options)
389 }
390
391 /// Reset the arena and parse `source`, returning a fully-owned [`ParseResult`].
392 ///
393 /// Unlike [`reparse`](ParserContext::reparse), the returned result has no
394 /// lifetime parameters and can be stored anywhere. The arena is reused for
395 /// the parse but the output is immediately converted to owned types, so
396 /// there is no borrow on `self` after this call returns.
397 pub fn reparse_owned(&mut self, source: &str) -> ParseResult {
398 self.arena.reset();
399 ParseResult::from_arena_result(parse_arena_with(&self.arena, source, &self.options))
400 }
401}
402
403impl Default for ParserContext {
404 fn default() -> Self {
405 Self::new()
406 }
407}