Skip to main content

php_lsp/document/
ast.rs

1//! Core AST infrastructure: arena-backed `ParsedDoc`, span utilities, and TypeHint formatting.
2//!
3//! Convention: a borrowed [`SourceView`] is idiomatically bound to `sv` across
4//! the crate (offset↔position math). A `ParsedDoc` value is `doc`. See the
5//! crate-root glossary in `lib.rs`.
6use std::mem::ManuallyDrop;
7use std::sync::{Arc, LazyLock, Mutex};
8
9use php_ast::{Program, Span, TypeHint, TypeHintKind};
10use tower_lsp::lsp_types::{Position, Range};
11
12// ── BumpPool ──────────────────────────────────────────────────────────────────
13
14const POOL_CAP: usize = 8;
15
16struct BumpPool {
17    // Box<Bump> required: arena_box.as_ref() is transmuted to &'static Bump in
18    // ParsedDoc::parse(). The Box keeps the Bump at a stable heap address so
19    // that reference remains valid after the Box is moved into ArenaGuard.
20    #[allow(clippy::vec_box)]
21    pool: Mutex<Vec<Box<bumpalo::Bump>>>,
22}
23
24impl BumpPool {
25    fn take(&self) -> Box<bumpalo::Bump> {
26        self.pool
27            .lock()
28            .unwrap()
29            .pop()
30            .unwrap_or_else(|| Box::new(bumpalo::Bump::new()))
31    }
32
33    fn give(&self, mut arena: Box<bumpalo::Bump>) {
34        arena.reset();
35        let mut p = self.pool.lock().unwrap();
36        if p.len() < POOL_CAP {
37            p.push(arena);
38        }
39    }
40}
41
42static BUMP_POOL: LazyLock<BumpPool> = LazyLock::new(|| BumpPool {
43    pool: Mutex::new(Vec::new()),
44});
45
46// ── ArenaGuard ────────────────────────────────────────────────────────────────
47
48/// Returns the arena to the pool on drop.
49struct ArenaGuard(Option<Box<bumpalo::Bump>>);
50
51impl Drop for ArenaGuard {
52    fn drop(&mut self) {
53        if let Some(arena) = self.0.take() {
54            BUMP_POOL.give(arena);
55        }
56    }
57}
58
59// ── ParsedDoc ─────────────────────────────────────────────────────────────────
60
61/// Owns a parsed PHP document: the bumpalo arena, source snapshot, and Program.
62///
63/// SAFETY invariants:
64/// - `program` uses `ManuallyDrop` and is explicitly dropped in `Drop::drop()`
65///   before any field auto-drop runs. This guarantees arena-allocated nodes are
66///   gone before `ArenaGuard` recycles the arena — regardless of field order.
67/// - `_source: Arc<str>` keeps the source text alive at a stable heap address.
68///   The transmuted `&'static str` in the parser arena remains valid for the
69///   lifetime of `ParsedDoc` because `_source` is dropped last (after `program`
70///   is manually dropped and after `_arena` is recycled).
71/// - The `'static` lifetimes in `ManuallyDrop<Box<Program<'static, 'static>>>`
72///   are erased versions of the true lifetimes `'_arena` and `'_source`. The
73///   public `program()` accessor re-attaches them to `&self`, preventing any
74///   reference from escaping beyond the lifetime of the `ParsedDoc`.
75pub struct ParsedDoc {
76    program: ManuallyDrop<Box<Program<'static, 'static>>>,
77    pub errors: Vec<php_rs_parser::diagnostics::ParseError>,
78    _source: Arc<str>,
79    line_starts: Vec<u32>,
80    _arena: ArenaGuard,
81}
82
83impl Drop for ParsedDoc {
84    fn drop(&mut self) {
85        // Drop program explicitly before any field auto-drop runs (including
86        // _arena). ManuallyDrop prevents a second drop after this method returns.
87        // SAFETY: called exactly once here; no other code drops this field.
88        unsafe { ManuallyDrop::drop(&mut self.program) };
89    }
90}
91
92// SAFETY: Program nodes contain only data; no thread-local state.
93unsafe impl Send for ParsedDoc {}
94unsafe impl Sync for ParsedDoc {}
95
96impl ParsedDoc {
97    /// Parse PHP source text. Accepts `String`, `&str`, or `Arc<str>` — when
98    /// the caller already holds an `Arc<str>` (e.g. the salsa `parsed_doc`
99    /// query), no heap allocation occurs (refcount bump only).
100    pub fn parse(source: impl Into<Arc<str>>) -> Self {
101        let source: Arc<str> = source.into();
102        // Take a pre-warmed arena from the pool (or allocate a fresh one).
103        let arena_box = BUMP_POOL.take();
104
105        // SAFETY: `Arc<str>` data lives at a stable heap address for as long as
106        // the Arc is alive. `_source` keeps the Arc alive for `ParsedDoc`'s
107        // lifetime, so the transmuted `&'static str` remains valid. The arena
108        // box is similarly stable — moving a `Box` moves the pointer, not the
109        // heap data.
110        let src_ref: &'static str = unsafe { std::mem::transmute::<&str, &'static str>(&*source) };
111        let arena_ref: &'static bumpalo::Bump = unsafe {
112            std::mem::transmute::<&bumpalo::Bump, &'static bumpalo::Bump>(arena_box.as_ref())
113        };
114
115        let result = php_rs_parser::parse_arena(arena_ref, src_ref);
116
117        let line_starts = build_line_starts(src_ref);
118
119        ParsedDoc {
120            program: ManuallyDrop::new(Box::new(result.program)),
121            errors: result.errors,
122            _source: source,
123            line_starts,
124            _arena: ArenaGuard(Some(arena_box)),
125        }
126    }
127
128    /// Borrow the program with lifetimes bounded by `&self`.
129    ///
130    /// SAFETY: covariance of `Program<'arena, 'src>` in both parameters lets
131    /// `&Program<'static, 'static>` shorten to `&Program<'_, '_>`.
132    #[inline]
133    pub fn program(&self) -> &Program<'_, '_> {
134        &self.program
135    }
136
137    /// Borrow the source text used when parsing.
138    #[inline]
139    pub fn source(&self) -> &str {
140        &self._source
141    }
142
143    /// Clone the `Arc<str>` backing the source text (refcount bump only).
144    ///
145    /// Callers that need to store the same source text in a salsa input
146    /// should use this to get the identical pointer — enabling cheap
147    /// `Arc::ptr_eq` validation in the `parsed_cache`.
148    #[inline]
149    pub fn source_arc(&self) -> Arc<str> {
150        self._source.clone()
151    }
152
153    /// Borrow the precomputed line-start byte offsets.
154    /// `line_starts[i]` is the byte offset of the first character on line `i`.
155    pub fn line_starts(&self) -> &[u32] {
156        &self.line_starts
157    }
158
159    /// Bundle source and line index for position lookups.
160    pub fn view(&self) -> SourceView<'_> {
161        SourceView {
162            source: self.source(),
163            line_starts: self.line_starts(),
164        }
165    }
166}
167
168impl Default for ParsedDoc {
169    fn default() -> Self {
170        ParsedDoc::parse("")
171    }
172}
173
174// ── Span / position utilities ─────────────────────────────────────────────────
175
176/// Build a table of byte offsets for the start of each line.
177/// `result[i]` is the byte offset of the first character on line `i`.
178fn build_line_starts(source: &str) -> Vec<u32> {
179    let mut starts = vec![0u32];
180    for (i, b) in source.bytes().enumerate() {
181        if b == b'\n' {
182            starts.push(i as u32 + 1);
183        }
184    }
185    starts
186}
187
188/// Bundles source text with its precomputed line-start table.
189/// `Copy` so inner functions can pass it by value without lifetime annotation churn.
190#[derive(Copy, Clone)]
191pub struct SourceView<'a> {
192    source: &'a str,
193    line_starts: &'a [u32],
194}
195
196impl<'a> SourceView<'a> {
197    #[inline]
198    pub fn source(self) -> &'a str {
199        self.source
200    }
201
202    pub fn position_of(self, offset: u32) -> Position {
203        offset_to_position(self.source, self.line_starts, offset)
204    }
205
206    #[inline]
207    pub fn line_starts(self) -> &'a [u32] {
208        self.line_starts
209    }
210
211    /// O(log lines) line number for a byte offset. Cheaper than
212    /// `position_of(..).line` because it skips the UTF-16 column scan.
213    #[inline]
214    pub fn line_of(self, offset: u32) -> u32 {
215        match self.line_starts.partition_point(|&s| s <= offset) {
216            0 => 0,
217            i => (i - 1) as u32,
218        }
219    }
220
221    /// O(log lines) UTF-16 `Position` -> byte offset. Uses the precomputed
222    /// `line_starts` table to jump to the target line and only scans that
223    /// line's characters. Much faster than a linear scan over the whole
224    /// source when the cursor is late in the file.
225    pub fn byte_of_position(self, pos: Position) -> u32 {
226        let line_idx = pos.line as usize;
227        let line_start = self.line_starts.get(line_idx).copied().unwrap_or(0) as usize;
228        let line_end = self
229            .line_starts
230            .get(line_idx + 1)
231            .map(|&s| (s as usize).saturating_sub(1))
232            .unwrap_or(self.source.len());
233        let raw = &self.source[line_start..line_end.min(self.source.len())];
234        let line = raw.strip_suffix('\r').unwrap_or(raw);
235        let mut col_utf16: u32 = 0;
236        let mut byte_in_line: usize = 0;
237        for ch in line.chars() {
238            if col_utf16 >= pos.character {
239                break;
240            }
241            col_utf16 += ch.len_utf16() as u32;
242            byte_in_line += ch.len_utf8();
243        }
244        (line_start + byte_in_line) as u32
245    }
246
247    pub fn range_of(self, span: Span) -> Range {
248        Range {
249            start: self.position_of(span.start),
250            end: self.position_of(span.end),
251        }
252    }
253
254    pub fn name_range(self, name: &str) -> Range {
255        let start = str_offset(self.source, name).unwrap_or(0);
256        Range {
257            start: self.position_of(start),
258            end: self.position_of(start + name.len() as u32),
259        }
260    }
261
262    /// Like [`name_range`], but searches for `name` *within* `span` instead
263    /// of the whole source. Needed when the same name appears in earlier
264    /// docblock comments / other declarations — a global search would
265    /// otherwise point at the first textual occurrence, not the AST node
266    /// the caller actually meant.
267    ///
268    /// When `name` cannot be located anywhere (e.g. a synthetic `Ident::ERROR`
269    /// placeholder such as `"<error>"`, which never occurs in real source),
270    /// falls back to an empty range at `span.start` — guaranteed to be
271    /// contained in the caller's `span`, unlike byte offset 0.
272    pub fn name_range_in_span(self, name: &str, span: php_ast::Span) -> Range {
273        let s = span.start as usize;
274        let e = (span.end as usize).min(self.source.len());
275        let found = self
276            .source
277            .get(s..e)
278            .and_then(|slice| slice.find(name))
279            .map(|off| span.start + off as u32)
280            .or_else(|| str_offset(self.source, name));
281        match found {
282            Some(start) => Range {
283                start: self.position_of(start),
284                end: self.position_of(start + name.len() as u32),
285            },
286            None => {
287                let pos = self.position_of(span.start);
288                Range {
289                    start: pos,
290                    end: pos,
291                }
292            }
293        }
294    }
295
296    /// Like [`name_range_in_span`], but excludes any leading `#[...]` attribute
297    /// groups from the search span first.
298    ///
299    /// php-rs-parser captures a member's span starting at its first attribute
300    /// token, not at the declaration keyword/name — so searching the raw span
301    /// can match `name` as a substring inside an attribute argument (e.g. a
302    /// method named `index` matching `'blog_index'` in `#[Route(name: 'blog_index')]`)
303    /// instead of the actual identifier.
304    pub fn name_range_after_attrs(
305        self,
306        name: &str,
307        attributes: &[php_ast::Attribute<'_, '_>],
308        span: php_ast::Span,
309    ) -> Range {
310        let start = attributes.last().map(|a| a.span.end).unwrap_or(span.start);
311        self.name_range_in_span(name, php_ast::Span::new(start, span.end))
312    }
313}
314
315/// Convert a byte offset into `source` to an LSP `Position` (0-based line/char).
316///
317/// Uses a precomputed `line_starts` table for O(log n) binary search.
318/// Handles both LF-only and CRLF line endings: a trailing `\r` before `\n` is
319/// not counted as a column so that positions are consistent regardless of
320/// line-ending style.
321pub fn offset_to_position(source: &str, line_starts: &[u32], offset: u32) -> Position {
322    let offset_usize = (offset as usize).min(source.len());
323    // Binary search: find the last line_start ≤ offset.
324    let line = match line_starts.partition_point(|&s| s <= offset) {
325        0 => 0u32,
326        i => (i - 1) as u32,
327    };
328    let line_start = line_starts.get(line as usize).copied().unwrap_or(0) as usize;
329    let segment = &source[line_start..offset_usize];
330    // Strip trailing \r to handle CRLF: don't count \r as a column.
331    let segment = segment.strip_suffix('\r').unwrap_or(segment);
332    let character = segment.chars().map(|c| c.len_utf16() as u32).sum::<u32>();
333    Position { line, character }
334}
335
336/// Convert a `Span` (byte-offset pair) to an LSP `Range`.
337pub fn span_to_range(source: &str, line_starts: &[u32], span: Span) -> Range {
338    Range {
339        start: offset_to_position(source, line_starts, span.start),
340        end: offset_to_position(source, line_starts, span.end),
341    }
342}
343
344/// Return the byte offset of `substr` within `source`, or None if not found.
345///
346/// Uses pointer arithmetic when `substr` is a true sub-slice of `source`
347/// (i.e. arena-allocated names pointing into the same backing string).
348/// Falls back to a content search when the pointers differ — this handles
349/// tests and callers that pass a differently-allocated copy of the source.
350///
351/// When falling back, prefers matches that are preceded and followed by
352/// non-identifier characters (word boundaries), to avoid matching name parts
353/// (e.g., finding "B" in "Base" when searching for a class named "B").
354pub fn str_offset(source: &str, substr: &str) -> Option<u32> {
355    let src_ptr = source.as_ptr() as usize;
356    let sub_ptr = substr.as_ptr() as usize;
357    if sub_ptr >= src_ptr && sub_ptr + substr.len() <= src_ptr + source.len() {
358        return Some((sub_ptr - src_ptr) as u32);
359    }
360    // Fallback: locate by content with word-boundary preference.
361    // When pointer matching fails, prefer matches where substr is surrounded by
362    // non-identifier characters to avoid matching partial names (e.g., "B" in "Base").
363    let mut search_pos = 0;
364    while let Some(offset) = source[search_pos..].find(substr) {
365        let abs_offset = search_pos + offset;
366        let is_start_boundary = abs_offset == 0
367            || !source[..abs_offset]
368                .chars()
369                .last()
370                .map(|c| c.is_alphanumeric() || c == '_')
371                .unwrap_or(false);
372        let end_pos = abs_offset + substr.len();
373        let is_end_boundary = end_pos >= source.len()
374            || !source[end_pos..]
375                .chars()
376                .next()
377                .map(|c| c.is_alphanumeric() || c == '_')
378                .unwrap_or(false);
379
380        if is_start_boundary && is_end_boundary {
381            return Some(abs_offset as u32);
382        }
383
384        search_pos = abs_offset + 1;
385    }
386    None
387}
388
389/// Build an LSP `Range` for a name that is a sub-slice of `source`, or None if not found.
390pub fn name_range(source: &str, line_starts: &[u32], name: &str) -> Option<Range> {
391    let start = str_offset(source, name)?;
392    Some(Range {
393        start: offset_to_position(source, line_starts, start),
394        end: offset_to_position(source, line_starts, start + name.len() as u32),
395    })
396}
397
398/// Find a name within a specific byte range of the source, with word-boundary matching.
399/// Returns the absolute byte offset if found, None otherwise.
400pub fn str_offset_in_range(source: &str, span: Span, name: &str) -> Option<u32> {
401    let span_start = span.start as usize;
402    let span_end = span.end as usize;
403    if span_end > source.len() {
404        return None;
405    }
406    let span_text = &source[span_start..span_end];
407    let offset = str_offset(span_text, name)?;
408    Some(span_start as u32 + offset)
409}
410
411// ── TypeHint formatting ────────────────────────────────────────────────────────
412
413/// Format a `TypeHint` as a PHP type string, e.g. `?int`, `string|null`.
414pub fn format_type_hint(hint: &TypeHint<'_, '_>) -> String {
415    fmt_kind(&hint.kind)
416}
417
418fn fmt_kind(kind: &TypeHintKind<'_, '_>) -> String {
419    match kind {
420        TypeHintKind::Named(name) => name.to_string_repr().to_string(),
421        TypeHintKind::Keyword(builtin, _) => builtin.as_str().to_string(),
422        TypeHintKind::Nullable(inner) => format!("?{}", format_type_hint(inner)),
423        TypeHintKind::Union(types) => types
424            .iter()
425            .map(format_type_hint)
426            .collect::<Vec<_>>()
427            .join("|"),
428        TypeHintKind::Intersection(types) => types
429            .iter()
430            .map(format_type_hint)
431            .collect::<Vec<_>>()
432            .join("&"),
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn parses_empty_source() {
442        let doc = ParsedDoc::parse("<?php".to_string());
443        assert!(doc.errors.is_empty());
444        assert!(doc.program().stmts.is_empty());
445    }
446
447    #[test]
448    fn parses_function() {
449        let doc = ParsedDoc::parse("<?php\nfunction foo() {}".to_string());
450        assert_eq!(doc.program().stmts.len(), 1);
451    }
452
453    #[test]
454    fn offset_to_position_first_line() {
455        let src = "<?php\nfoo";
456        let doc = ParsedDoc::parse(src.to_string());
457        assert_eq!(
458            offset_to_position(src, doc.line_starts(), 0),
459            Position {
460                line: 0,
461                character: 0
462            }
463        );
464    }
465
466    #[test]
467    fn offset_to_position_second_line() {
468        // "<?php\n" — offset 6 is start of line 1
469        let src = "<?php\nfoo";
470        let doc = ParsedDoc::parse(src.to_string());
471        assert_eq!(
472            offset_to_position(src, doc.line_starts(), 6),
473            Position {
474                line: 1,
475                character: 0
476            }
477        );
478    }
479
480    #[test]
481    fn offset_to_position_multibyte_utf16() {
482        // "é" is U+00E9: 2 UTF-8 bytes, 1 UTF-16 code unit.
483        // "😀" is U+1F600: 4 UTF-8 bytes, 2 UTF-16 code units.
484        // source: "a😀b" — byte offsets: a=0, 😀=1..5, b=5
485        // UTF-16:            a=col 0, 😀=col 1..3, b=col 3
486        let src = "a\u{1F600}b";
487        let doc = ParsedDoc::parse(src.to_string());
488        assert_eq!(
489            offset_to_position(src, doc.line_starts(), 5), // byte offset of 'b'
490            Position {
491                line: 0,
492                character: 3
493            }  // UTF-16 col 3
494        );
495    }
496
497    #[test]
498    fn offset_to_position_crlf_start_of_line() {
499        // CRLF: offset pointing to first char of line 1 must give character=0.
500        // "foo\r\nbar": f=0 o=1 o=2 \r=3 \n=4 b=5 a=6 r=7
501        let src = "foo\r\nbar";
502        let doc = ParsedDoc::parse(src.to_string());
503        assert_eq!(
504            offset_to_position(src, doc.line_starts(), 5), // 'b'
505            Position {
506                line: 1,
507                character: 0
508            }
509        );
510    }
511
512    #[test]
513    fn offset_to_position_crlf_does_not_count_cr_in_column() {
514        // Offset pointing to the \r itself must not count it as a column.
515        // "foo\r\nbar": the \r is at offset 3, column must be 3 (length of "foo").
516        let src = "foo\r\nbar";
517        let doc = ParsedDoc::parse(src.to_string());
518        assert_eq!(
519            offset_to_position(src, doc.line_starts(), 3), // '\r'
520            Position {
521                line: 0,
522                character: 3
523            }
524        );
525    }
526
527    #[test]
528    fn offset_to_position_crlf_multiline() {
529        // Multiple CRLF lines: columns must not accumulate stray \r counts.
530        // "a\r\nb\r\nc": a=0 \r=1 \n=2 b=3 \r=4 \n=5 c=6
531        let src = "a\r\nb\r\nc";
532        let doc = ParsedDoc::parse(src.to_string());
533        assert_eq!(
534            offset_to_position(src, doc.line_starts(), 6), // 'c'
535            Position {
536                line: 2,
537                character: 0
538            }
539        );
540        assert_eq!(
541            offset_to_position(src, doc.line_starts(), 3), // 'b'
542            Position {
543                line: 1,
544                character: 0
545            }
546        );
547    }
548
549    #[test]
550    fn str_offset_finds_substr() {
551        let src = "<?php\nfunction foo() {}";
552        let name = &src[15..18]; // "foo"
553        assert_eq!(str_offset(src, name), Some(15));
554    }
555
556    #[test]
557    fn str_offset_content_fallback_for_different_allocation() {
558        // "foo" is a separately owned String (not a sub-slice of the source),
559        // so pointer arithmetic fails. The fallback finds it by content.
560        let owned = "foo".to_string();
561        assert_eq!(str_offset("<?php foo", &owned), Some(6));
562    }
563
564    #[test]
565    fn str_offset_unrelated_content_returns_none() {
566        let owned = "bar".to_string();
567        assert_eq!(str_offset("<?php foo", &owned), None);
568    }
569}