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