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_versioned`] to target a specific PHP version. Syntax that
36//! requires a higher version is still parsed into the AST, but a
37//! [`diagnostics::ParseError::VersionTooLow`] diagnostic is emitted.
38//!
39//! ```
40//! let result = php_rs_parser::parse_versioned(
41//! "<?php enum Status { case Active; }",
42//! php_rs_parser::PhpVersion::Php80,
43//! );
44//! assert!(!result.errors.is_empty()); // enums require PHP 8.1
45//! ```
46//!
47//! # Multi-file cache
48//!
49//! [`parse`] returns a [`ParseResult`] with no lifetime parameters — fully
50//! owned, storable in a `HashMap`, sendable across threads.
51//!
52//! ```
53//! use std::collections::HashMap;
54//! use std::path::PathBuf;
55//!
56//! let mut cache: HashMap<PathBuf, php_rs_parser::ParseResult> = HashMap::new();
57//! cache.insert(PathBuf::from("a.php"), php_rs_parser::parse("<?php echo 1;"));
58//! ```
59//!
60//! # Arena API (LSP / hot-path usage)
61//!
62//! Use [`parse_arena`] / [`ParserContext`] when you need maximum throughput
63//! and can manage the arena lifetime yourself. The returned
64//! [`ArenaParseResult`] borrows from both the arena and the source string —
65//! no allocation copying occurs.
66//!
67//! ```
68//! let mut ctx = php_rs_parser::ParserContext::new();
69//!
70//! let result = ctx.reparse("<?php echo 1;");
71//! assert!(result.errors.is_empty());
72//! drop(result); // must be dropped before the next reparse
73//!
74//! let result = ctx.reparse("<?php echo 2;");
75//! assert!(result.errors.is_empty());
76//! ```
77//!
78//! Use [`parse_arena_raw`] instead of [`parse_arena`] when you never need
79//! line/column positions — it skips building the [`source_map::SourceMap`]
80//! and is slightly faster.
81
82#![warn(missing_docs)]
83
84/// Parse errors and warnings.
85pub mod diagnostics;
86pub(crate) mod expr;
87pub mod instrument;
88pub(crate) mod parser;
89pub use phpdoc_parser as phpdoc;
90pub(crate) mod precedence;
91/// Byte offset to line/column mapping.
92pub mod source_map;
93pub(crate) mod stmt;
94/// Target PHP version selection.
95pub mod version;
96
97use diagnostics::ParseError;
98use php_ast::owned::Comment as OwnedComment;
99use php_ast::{owned::to_owned_program, Comment, Program};
100use source_map::SourceMap;
101pub use version::PhpVersion;
102
103/// Lifetime-free result of parsing a PHP source string.
104///
105/// This is the primary return type of [`parse`] and [`parse_versioned`]. The
106/// AST is fully owned (`Box<str>`, `Box<[T]>`) so it can be stored in a
107/// `HashMap`, sent across threads, or cached alongside other data without
108/// fighting the borrow checker.
109///
110/// Use [`parse_arena`] or [`ParserContext`] when you need the arena-allocated
111/// form for maximum throughput in tight loops or LSP re-parse scenarios.
112pub struct ParseResult {
113 /// The original source text, owned.
114 pub source: String,
115 /// The parsed AST, fully owned with no lifetime parameters.
116 pub program: php_ast::owned::Program,
117 /// All comments found in the source, in source order. Doc-block comments
118 /// attached to a declaration are stored in the declaration node's
119 /// `doc_comment` field, not here.
120 pub comments: Vec<php_ast::owned::Comment>,
121 /// Parse errors and diagnostics. Empty on a successful parse.
122 pub errors: Vec<ParseError>,
123 /// `true` when the error list was capped and further errors were dropped.
124 pub errors_truncated: bool,
125 /// Pre-computed line index for span-to-line/column resolution.
126 pub source_map: SourceMap,
127}
128
129impl ParseResult {
130 fn from_arena_result(result: ArenaParseResult<'_, '_>) -> Self {
131 let program = to_owned_program(&result.program);
132 let comments = result
133 .comments
134 .iter()
135 .map(|c| OwnedComment {
136 kind: c.kind,
137 text: c.text.into(),
138 span: c.span,
139 })
140 .collect();
141 Self {
142 source: result.source.to_owned(),
143 program,
144 comments,
145 errors: result.errors,
146 errors_truncated: result.errors_truncated,
147 source_map: result.source_map,
148 }
149 }
150}
151
152/// Arena-allocated result of parsing a PHP source string.
153///
154/// Returned by [`parse_arena`], [`parse_arena_versioned`], and
155/// [`ParserContext::reparse`]. Both the AST and the source text are borrowed,
156/// so this type has two lifetime parameters. Use [`ParseResult`] (from
157/// [`parse`]) when you need an owned, lifetime-free result.
158pub struct ArenaParseResult<'arena, 'src> {
159 /// The original source text. Useful for extracting text from spans
160 /// via `&result.source[span.start as usize..span.end as usize]`.
161 pub source: &'src str,
162 /// The parsed AST. Always produced, even when errors are present.
163 pub program: Program<'arena, 'src>,
164 /// All comments found in the source, in source order, **except** `/** */`
165 /// doc-block comments that are immediately attached to a declaration.
166 ///
167 /// When the parser encounters a `/** */` comment directly before a
168 /// function, class, method, property, constant, or enum case, it removes
169 /// that comment from this list and stores it in the declaration node's
170 /// `doc_comment` field instead. The two collections are therefore
171 /// **disjoint**: iterating both without deduplication will double-count
172 /// nothing, but iterating only one will miss the other's entries.
173 ///
174 /// To process every comment in the file, iterate `result.comments` (for
175 /// line, hash, block, and unattached doc comments) and also visit each
176 /// declaration node's `doc_comment` field. Or use
177 /// [`php_ast::visitor::walk_comments`] with a [`Visitor`](php_ast::visitor::Visitor) that also
178 /// overrides the declaration visit methods.
179 pub comments: Vec<Comment<'src>>,
180 /// Parse errors and diagnostics. Empty on a successful parse.
181 pub errors: Vec<ParseError>,
182 /// `true` when the error list was capped at the internal limit and further
183 /// errors were silently dropped. Callers that need a complete error list
184 /// (e.g. linters) should treat this as an incomplete result.
185 pub errors_truncated: bool,
186 /// Pre-computed line index for resolving byte offsets in [`Span`](php_ast::Span)
187 /// to line/column positions. Use [`SourceMap::offset_to_line_col`] or
188 /// [`SourceMap::span_to_line_col`] to convert.
189 pub source_map: SourceMap,
190}
191
192/// Parse PHP `source` using the latest supported PHP version (currently 8.5).
193///
194/// Returns a fully-owned [`ParseResult`] with no lifetime parameters. The
195/// internal arena is created, used, and converted within this call.
196///
197/// Use [`parse_arena`] when you need the raw arena-allocated AST for maximum
198/// throughput (no allocation copying).
199pub fn parse(source: &str) -> ParseResult {
200 let arena = bumpalo::Bump::new();
201 ParseResult::from_arena_result(parse_arena(&arena, source))
202}
203
204/// Parse `source` targeting the given PHP `version`.
205///
206/// Syntax that requires a higher version than `version` is still parsed and
207/// included in the AST, but a [`diagnostics::ParseError::VersionTooLow`] error
208/// is also emitted so callers can report it to the user.
209///
210/// Returns a fully-owned [`ParseResult`]. Use [`parse_arena_versioned`] for the
211/// arena form.
212pub fn parse_versioned(source: &str, version: PhpVersion) -> ParseResult {
213 let arena = bumpalo::Bump::new();
214 ParseResult::from_arena_result(parse_arena_versioned(&arena, source, version))
215}
216
217/// Parse PHP `source` using the latest supported PHP version, returning an
218/// arena-allocated [`ArenaParseResult`].
219///
220/// The `arena` is used for all AST allocations, giving callers control over
221/// memory lifetime. The returned result borrows from both the arena and the
222/// source string.
223///
224/// Prefer [`parse`] unless you are managing the arena yourself for performance
225/// reasons (e.g. LSP re-parsing with [`ParserContext`]). Use [`parse_arena_raw`]
226/// when you never need line/column positions from `result.source_map`.
227pub fn parse_arena<'arena, 'src>(
228 arena: &'arena bumpalo::Bump,
229 source: &'src str,
230) -> ArenaParseResult<'arena, 'src> {
231 let mut parser = parser::Parser::new(arena, source);
232 let program = parser.parse_program();
233 let errors_truncated = parser.errors_truncated();
234 ArenaParseResult {
235 source,
236 program,
237 comments: parser.take_comments(),
238 errors: parser.into_errors(),
239 errors_truncated,
240 source_map: SourceMap::new(source),
241 }
242}
243
244/// Like [`parse_arena`], but skips building the [`source_map::SourceMap`].
245///
246/// `result.source_map` is a no-op empty map. Use this when you only need the
247/// AST and errors — formatters, linters, and batch processors that never call
248/// `source_map.offset_to_line_col` or `source_map.span_to_line_col`.
249pub fn parse_arena_raw<'arena, 'src>(
250 arena: &'arena bumpalo::Bump,
251 source: &'src str,
252) -> ArenaParseResult<'arena, 'src> {
253 let mut parser = parser::Parser::new(arena, source);
254 let program = parser.parse_program();
255 let errors_truncated = parser.errors_truncated();
256 ArenaParseResult {
257 source,
258 program,
259 comments: parser.take_comments(),
260 errors: parser.into_errors(),
261 errors_truncated,
262 source_map: SourceMap::empty(),
263 }
264}
265
266/// Parse `source` targeting the given PHP `version`, returning an
267/// arena-allocated [`ArenaParseResult`].
268///
269/// See [`parse_arena`] for arena lifetime semantics and [`parse_versioned`] for
270/// version-gating behaviour.
271pub fn parse_arena_versioned<'arena, 'src>(
272 arena: &'arena bumpalo::Bump,
273 source: &'src str,
274 version: PhpVersion,
275) -> ArenaParseResult<'arena, 'src> {
276 let mut parser = parser::Parser::with_version(arena, source, version);
277 let program = parser.parse_program();
278 let errors_truncated = parser.errors_truncated();
279 ArenaParseResult {
280 source,
281 program,
282 comments: parser.take_comments(),
283 errors: parser.into_errors(),
284 errors_truncated,
285 source_map: SourceMap::new(source),
286 }
287}
288
289/// A reusable parse context that keeps a `bumpalo::Bump` arena alive between
290/// re-parses, resetting it (O(1)) instead of dropping and reallocating.
291///
292/// This is the preferred entry point for LSP servers or any tool that parses
293/// the same document repeatedly. Once the arena has grown to accommodate the
294/// largest document seen, subsequent parses reuse the backing memory without
295/// any new allocations.
296///
297/// The Rust lifetime system enforces safety: the returned [`ArenaParseResult`]
298/// borrows from `self`, so the borrow checker prevents calling [`reparse`] or
299/// [`reparse_versioned`] again while the previous result is still alive.
300///
301/// [`reparse`]: ParserContext::reparse
302/// [`reparse_versioned`]: ParserContext::reparse_versioned
303///
304/// # Example
305///
306/// ```
307/// let mut ctx = php_rs_parser::ParserContext::new();
308///
309/// let result = ctx.reparse("<?php echo 1;");
310/// assert!(result.errors.is_empty());
311/// drop(result); // must be dropped before the next reparse
312///
313/// let result = ctx.reparse("<?php echo 2;");
314/// assert!(result.errors.is_empty());
315/// ```
316pub struct ParserContext {
317 arena: bumpalo::Bump,
318}
319
320impl ParserContext {
321 /// Create a new context with an empty arena.
322 pub fn new() -> Self {
323 Self {
324 arena: bumpalo::Bump::new(),
325 }
326 }
327
328 /// Reset the arena and parse `source` using PHP 8.5 (the latest version).
329 ///
330 /// The previous [`ArenaParseResult`] **must be dropped** before calling
331 /// this method. The borrow checker enforces this: the returned result
332 /// borrows `self` for the duration of its lifetime, so a second call while
333 /// the first result is still live is a compile-time error.
334 pub fn reparse<'a, 'src>(&'a mut self, source: &'src str) -> ArenaParseResult<'a, 'src> {
335 self.arena.reset();
336 parse_arena(&self.arena, source)
337 }
338
339 /// Reset the arena and parse `source` targeting the given PHP `version`.
340 ///
341 /// See [`reparse`](ParserContext::reparse) for lifetime safety notes.
342 pub fn reparse_versioned<'a, 'src>(
343 &'a mut self,
344 source: &'src str,
345 version: PhpVersion,
346 ) -> ArenaParseResult<'a, 'src> {
347 self.arena.reset();
348 parse_arena_versioned(&self.arena, source, version)
349 }
350
351 /// Reset the arena and parse `source`, returning a fully-owned [`ParseResult`].
352 ///
353 /// Unlike [`reparse`](ParserContext::reparse), the returned result has no
354 /// lifetime parameters and can be stored anywhere. The arena is reused for
355 /// the parse but the output is immediately converted to owned types, so
356 /// there is no borrow on `self` after this call returns.
357 pub fn reparse_owned(&mut self, source: &str) -> ParseResult {
358 self.arena.reset();
359 ParseResult::from_arena_result(parse_arena(&self.arena, source))
360 }
361
362 /// Reset the arena and parse `source` targeting the given PHP `version`,
363 /// returning a fully-owned [`ParseResult`].
364 ///
365 /// See [`reparse_owned`](ParserContext::reparse_owned) for ownership notes
366 /// and [`reparse_versioned`](ParserContext::reparse_versioned) for version
367 /// semantics.
368 pub fn reparse_owned_versioned(&mut self, source: &str, version: PhpVersion) -> ParseResult {
369 self.arena.reset();
370 ParseResult::from_arena_result(parse_arena_versioned(&self.arena, source, version))
371 }
372}
373
374impl Default for ParserContext {
375 fn default() -> Self {
376 Self::new()
377 }
378}