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    /// Convert many byte offsets to LSP positions in one pass per touched line.
223    ///
224    /// Calling [`Self::position_of`] repeatedly is fine for a handful of spans,
225    /// but local-variable features can produce hundreds of ranges in one large
226    /// method. Grouping by line avoids rescanning the same line prefix for
227    /// every occurrence.
228    pub fn positions_of_offsets(self, offsets: &[u32]) -> Vec<Position> {
229        let mut out = vec![
230            Position {
231                line: 0,
232                character: 0,
233            };
234            offsets.len()
235        ];
236        if offsets.is_empty() {
237            return out;
238        }
239
240        let mut indexed: Vec<(usize, u32)> = offsets.iter().copied().enumerate().collect();
241        indexed.sort_by_key(|&(_, offset)| offset);
242
243        let mut i = 0usize;
244        while i < indexed.len() {
245            let offset = indexed[i].1.min(self.source.len() as u32);
246            let line = self.line_of(offset);
247            let line_idx = line as usize;
248            let line_start = self.line_starts.get(line_idx).copied().unwrap_or(0) as usize;
249            let line_end = self
250                .line_starts
251                .get(line_idx + 1)
252                .map(|&s| (s as usize).saturating_sub(1))
253                .unwrap_or(self.source.len())
254                .min(self.source.len());
255            let raw = &self.source[line_start..line_end];
256            let line_text = raw.strip_suffix('\r').unwrap_or(raw);
257
258            let mut group_end = i + 1;
259            while group_end < indexed.len() && self.line_of(indexed[group_end].1) == line {
260                group_end += 1;
261            }
262
263            let mut char_iter = line_text.char_indices().peekable();
264            let mut utf16_col = 0u32;
265            for &(original_idx, target_offset) in &indexed[i..group_end] {
266                let target_in_line = (target_offset as usize)
267                    .saturating_sub(line_start)
268                    .min(line_text.len());
269                while let Some(&(byte_idx, ch)) = char_iter.peek() {
270                    if byte_idx >= target_in_line {
271                        break;
272                    }
273                    utf16_col += ch.len_utf16() as u32;
274                    char_iter.next();
275                }
276                out[original_idx] = Position {
277                    line,
278                    character: utf16_col,
279                };
280            }
281
282            i = group_end;
283        }
284
285        out
286    }
287
288    #[inline]
289    pub fn line_starts(self) -> &'a [u32] {
290        self.line_starts
291    }
292
293    /// O(log lines) line number for a byte offset. Cheaper than
294    /// `position_of(..).line` because it skips the UTF-16 column scan.
295    #[inline]
296    pub fn line_of(self, offset: u32) -> u32 {
297        match self.line_starts.partition_point(|&s| s <= offset) {
298            0 => 0,
299            i => (i - 1) as u32,
300        }
301    }
302
303    /// O(log lines) UTF-16 `Position` -> byte offset. Uses the precomputed
304    /// `line_starts` table to jump to the target line and only scans that
305    /// line's characters. Much faster than a linear scan over the whole
306    /// source when the cursor is late in the file.
307    pub fn byte_of_position(self, pos: Position) -> u32 {
308        let line_idx = pos.line as usize;
309        let Some(&line_start) = self.line_starts.get(line_idx) else {
310            return self.source.len() as u32;
311        };
312        let line_start = line_start as usize;
313        let line_end = self
314            .line_starts
315            .get(line_idx + 1)
316            .map(|&s| (s as usize).saturating_sub(1))
317            .unwrap_or(self.source.len());
318        let raw = &self.source[line_start..line_end.min(self.source.len())];
319        let line = raw.strip_suffix('\r').unwrap_or(raw);
320        let mut col_utf16: u32 = 0;
321        let mut byte_in_line: usize = 0;
322        for ch in line.chars() {
323            if col_utf16 >= pos.character {
324                break;
325            }
326            col_utf16 += ch.len_utf16() as u32;
327            byte_in_line += ch.len_utf8();
328        }
329        (line_start + byte_in_line) as u32
330    }
331
332    pub fn range_of(self, span: Span) -> Range {
333        Range {
334            start: self.position_of(span.start),
335            end: self.position_of(span.end),
336        }
337    }
338
339    pub fn name_range(self, name: &str) -> Range {
340        let start = str_offset(self.source, name).unwrap_or(0);
341        Range {
342            start: self.position_of(start),
343            end: self.position_of(start + name.len() as u32),
344        }
345    }
346
347    /// Like [`name_range`], but searches for `name` *within* `span` instead
348    /// of the whole source. Needed when the same name appears in earlier
349    /// docblock comments / other declarations — a global search would
350    /// otherwise point at the first textual occurrence, not the AST node
351    /// the caller actually meant.
352    ///
353    /// When `name` cannot be located anywhere (e.g. a synthetic `Ident::ERROR`
354    /// placeholder such as `"<error>"`, which never occurs in real source),
355    /// falls back to an empty range at `span.start` — guaranteed to be
356    /// contained in the caller's `span`, unlike byte offset 0.
357    pub fn name_range_in_span(self, name: &str, span: php_ast::Span) -> Range {
358        let s = span.start as usize;
359        let e = (span.end as usize).min(self.source.len());
360        let found = self
361            .source
362            .get(s..e)
363            .and_then(|slice| slice.find(name))
364            .map(|off| span.start + off as u32)
365            .or_else(|| str_offset(self.source, name));
366        match found {
367            Some(start) => Range {
368                start: self.position_of(start),
369                end: self.position_of(start + name.len() as u32),
370            },
371            None => {
372                let pos = self.position_of(span.start);
373                Range {
374                    start: pos,
375                    end: pos,
376                }
377            }
378        }
379    }
380
381    /// Like [`name_range_in_span`], but excludes any leading `#[...]` attribute
382    /// groups from the search span first.
383    ///
384    /// php-rs-parser captures a member's span starting at its first attribute
385    /// token, not at the declaration keyword/name — so searching the raw span
386    /// can match `name` as a substring inside an attribute argument (e.g. a
387    /// method named `index` matching `'blog_index'` in `#[Route(name: 'blog_index')]`)
388    /// instead of the actual identifier.
389    pub fn name_range_after_attrs(
390        self,
391        name: &str,
392        attributes: &[php_ast::Attribute<'_, '_>],
393        span: php_ast::Span,
394    ) -> Range {
395        let start = attributes.last().map(|a| a.span.end).unwrap_or(span.start);
396        self.name_range_in_span(name, php_ast::Span::new(start, span.end))
397    }
398}
399
400/// Convert a byte offset into `source` to an LSP `Position` (0-based line/char).
401///
402/// Uses a precomputed `line_starts` table for O(log n) binary search.
403/// Handles both LF-only and CRLF line endings: a trailing `\r` before `\n` is
404/// not counted as a column so that positions are consistent regardless of
405/// line-ending style.
406pub fn offset_to_position(source: &str, line_starts: &[u32], offset: u32) -> Position {
407    let offset_usize = (offset as usize).min(source.len());
408    // Binary search: find the last line_start ≤ offset.
409    let line = match line_starts.partition_point(|&s| s <= offset) {
410        0 => 0u32,
411        i => (i - 1) as u32,
412    };
413    let line_start = line_starts.get(line as usize).copied().unwrap_or(0) as usize;
414    let segment = &source[line_start..offset_usize];
415    // Strip trailing \r to handle CRLF: don't count \r as a column.
416    let segment = segment.strip_suffix('\r').unwrap_or(segment);
417    let character = segment.chars().map(|c| c.len_utf16() as u32).sum::<u32>();
418    Position { line, character }
419}
420
421/// Return the byte offset of `substr` within `source`, or None if not found.
422///
423/// Uses pointer arithmetic when `substr` is a true sub-slice of `source`
424/// (i.e. arena-allocated names pointing into the same backing string).
425/// Falls back to a content search when the pointers differ — this handles
426/// tests and callers that pass a differently-allocated copy of the source.
427///
428/// When falling back, prefers matches that are preceded and followed by
429/// non-identifier characters (word boundaries), to avoid matching name parts
430/// (e.g., finding "B" in "Base" when searching for a class named "B").
431pub fn str_offset(source: &str, substr: &str) -> Option<u32> {
432    let src_ptr = source.as_ptr() as usize;
433    let sub_ptr = substr.as_ptr() as usize;
434    if sub_ptr >= src_ptr && sub_ptr + substr.len() <= src_ptr + source.len() {
435        return Some((sub_ptr - src_ptr) as u32);
436    }
437    // Fallback: locate by content with word-boundary preference.
438    // When pointer matching fails, prefer matches where substr is surrounded by
439    // non-identifier characters to avoid matching partial names (e.g., "B" in "Base").
440    let mut search_pos = 0;
441    while let Some(offset) = source[search_pos..].find(substr) {
442        let abs_offset = search_pos + offset;
443        let is_start_boundary = abs_offset == 0
444            || !source[..abs_offset]
445                .chars()
446                .last()
447                .map(|c| c.is_alphanumeric() || c == '_')
448                .unwrap_or(false);
449        let end_pos = abs_offset + substr.len();
450        let is_end_boundary = end_pos >= source.len()
451            || !source[end_pos..]
452                .chars()
453                .next()
454                .map(|c| c.is_alphanumeric() || c == '_')
455                .unwrap_or(false);
456
457        if is_start_boundary && is_end_boundary {
458            return Some(abs_offset as u32);
459        }
460
461        search_pos = abs_offset + 1;
462    }
463    None
464}
465
466/// Build an LSP `Range` for a name that is a sub-slice of `source`, or None if not found.
467pub fn name_range(source: &str, line_starts: &[u32], name: &str) -> Option<Range> {
468    let start = str_offset(source, name)?;
469    Some(Range {
470        start: offset_to_position(source, line_starts, start),
471        end: offset_to_position(source, line_starts, start + name.len() as u32),
472    })
473}
474
475/// Find a name within a specific byte range of the source, with word-boundary matching.
476/// Returns the absolute byte offset if found, None otherwise.
477pub fn str_offset_in_range(source: &str, span: Span, name: &str) -> Option<u32> {
478    let span_start = span.start as usize;
479    let span_end = span.end as usize;
480    if span_end > source.len() {
481        return None;
482    }
483    let span_text = &source[span_start..span_end];
484    let offset = str_offset(span_text, name)?;
485    Some(span_start as u32 + offset)
486}
487
488/// The `(alias-or-short-name, FQN)` pair for one `use` import item: the
489/// import's local name (an explicit alias when present, otherwise the last
490/// segment of the imported FQN) paired with its fully-qualified name.
491/// Shared by `navigation::references`'s import-map builder and
492/// `index::file_index`'s persistent-index extraction, which otherwise
493/// duplicated this exact per-item computation.
494pub fn use_item_alias_and_fqn(item: &php_ast::UseItem<'_, '_>) -> (String, String) {
495    let fqn = item.name.to_string_repr().into_owned();
496    let alias = item
497        .alias
498        .map(|a| a.to_string())
499        .unwrap_or_else(|| crate::text::fqn_short_name(&fqn).to_string());
500    (alias, fqn)
501}
502
503// ── TypeHint formatting ────────────────────────────────────────────────────────
504
505/// Format a `TypeHint` as a PHP type string, e.g. `?int`, `string|null`.
506pub fn format_type_hint(hint: &TypeHint<'_, '_>) -> String {
507    fmt_kind(&hint.kind)
508}
509
510fn fmt_kind(kind: &TypeHintKind<'_, '_>) -> String {
511    match kind {
512        TypeHintKind::Named(name) => name.to_string_repr().to_string(),
513        TypeHintKind::Keyword(builtin, _) => builtin.as_str().to_string(),
514        TypeHintKind::Nullable(inner) => format!("?{}", format_type_hint(inner)),
515        TypeHintKind::Union(types) => types
516            .iter()
517            .map(format_type_hint)
518            .collect::<Vec<_>>()
519            .join("|"),
520        TypeHintKind::Intersection(types) => types
521            .iter()
522            .map(format_type_hint)
523            .collect::<Vec<_>>()
524            .join("&"),
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn byte_of_position_past_last_line_clamps_to_end() {
534        let src = "<?php\nfoo";
535        let doc = ParsedDoc::parse(src.to_string());
536        let pos = Position {
537            line: 99,
538            character: 0,
539        };
540        assert_eq!(doc.view().byte_of_position(pos), src.len() as u32);
541    }
542
543    #[test]
544    fn parses_empty_source() {
545        let doc = ParsedDoc::parse("<?php".to_string());
546        assert!(doc.errors.is_empty());
547        assert!(doc.program().stmts.is_empty());
548    }
549
550    #[test]
551    fn parses_function() {
552        let doc = ParsedDoc::parse("<?php\nfunction foo() {}".to_string());
553        assert_eq!(doc.program().stmts.len(), 1);
554        match &doc.program().stmts[0].kind {
555            php_ast::StmtKind::Function(f) => assert_eq!(f.name.as_str(), Some("foo")),
556            other => panic!("expected a function declaration, got {other:?}"),
557        }
558    }
559
560    #[test]
561    fn offset_to_position_first_line() {
562        let src = "<?php\nfoo";
563        let doc = ParsedDoc::parse(src.to_string());
564        assert_eq!(
565            offset_to_position(src, doc.line_starts(), 0),
566            Position {
567                line: 0,
568                character: 0
569            }
570        );
571    }
572
573    #[test]
574    fn offset_to_position_second_line() {
575        // "<?php\n" — offset 6 is start of line 1
576        let src = "<?php\nfoo";
577        let doc = ParsedDoc::parse(src.to_string());
578        assert_eq!(
579            offset_to_position(src, doc.line_starts(), 6),
580            Position {
581                line: 1,
582                character: 0
583            }
584        );
585    }
586
587    #[test]
588    fn offset_to_position_multibyte_utf16() {
589        // "é" is U+00E9: 2 UTF-8 bytes, 1 UTF-16 code unit.
590        // "😀" is U+1F600: 4 UTF-8 bytes, 2 UTF-16 code units.
591        // source: "a😀b" — byte offsets: a=0, 😀=1..5, b=5
592        // UTF-16:            a=col 0, 😀=col 1..3, b=col 3
593        let src = "a\u{1F600}b";
594        let doc = ParsedDoc::parse(src.to_string());
595        assert_eq!(
596            offset_to_position(src, doc.line_starts(), 5), // byte offset of 'b'
597            Position {
598                line: 0,
599                character: 3
600            }  // UTF-16 col 3
601        );
602    }
603
604    #[test]
605    fn offset_to_position_crlf_start_of_line() {
606        // CRLF: offset pointing to first char of line 1 must give character=0.
607        // "foo\r\nbar": f=0 o=1 o=2 \r=3 \n=4 b=5 a=6 r=7
608        let src = "foo\r\nbar";
609        let doc = ParsedDoc::parse(src.to_string());
610        assert_eq!(
611            offset_to_position(src, doc.line_starts(), 5), // 'b'
612            Position {
613                line: 1,
614                character: 0
615            }
616        );
617    }
618
619    #[test]
620    fn offset_to_position_crlf_does_not_count_cr_in_column() {
621        // Offset pointing to the \r itself must not count it as a column.
622        // "foo\r\nbar": the \r is at offset 3, column must be 3 (length of "foo").
623        let src = "foo\r\nbar";
624        let doc = ParsedDoc::parse(src.to_string());
625        assert_eq!(
626            offset_to_position(src, doc.line_starts(), 3), // '\r'
627            Position {
628                line: 0,
629                character: 3
630            }
631        );
632    }
633
634    #[test]
635    fn offset_to_position_crlf_multiline() {
636        // Multiple CRLF lines: columns must not accumulate stray \r counts.
637        // "a\r\nb\r\nc": a=0 \r=1 \n=2 b=3 \r=4 \n=5 c=6
638        let src = "a\r\nb\r\nc";
639        let doc = ParsedDoc::parse(src.to_string());
640        assert_eq!(
641            offset_to_position(src, doc.line_starts(), 6), // 'c'
642            Position {
643                line: 2,
644                character: 0
645            }
646        );
647        assert_eq!(
648            offset_to_position(src, doc.line_starts(), 3), // 'b'
649            Position {
650                line: 1,
651                character: 0
652            }
653        );
654    }
655
656    #[test]
657    fn positions_of_offsets_matches_single_lookup() {
658        let src = "<?php\r\n$a = \"café\";\r\n$b = \"😀\";\r\n";
659        let doc = ParsedDoc::parse(src.to_string());
660        let offsets = vec![0, 6, 10, 18, 23, 28, 32, src.len() as u32];
661        let batched = doc.view().positions_of_offsets(&offsets);
662        let expected: Vec<Position> = offsets
663            .iter()
664            .map(|&offset| offset_to_position(src, doc.line_starts(), offset))
665            .collect();
666        assert_eq!(batched, expected);
667    }
668
669    #[test]
670    fn positions_of_offsets_preserves_input_order() {
671        let src = "a\nbb\nccc";
672        let doc = ParsedDoc::parse(src.to_string());
673        let offsets = vec![5, 0, 3, 1];
674        assert_eq!(
675            doc.view().positions_of_offsets(&offsets),
676            vec![
677                Position {
678                    line: 2,
679                    character: 0,
680                },
681                Position {
682                    line: 0,
683                    character: 0,
684                },
685                Position {
686                    line: 1,
687                    character: 1,
688                },
689                Position {
690                    line: 0,
691                    character: 1,
692                },
693            ]
694        );
695    }
696
697    #[test]
698    fn str_offset_finds_substr() {
699        let src = "<?php\nfunction foo() {}";
700        let name = &src[15..18]; // "foo"
701        assert_eq!(str_offset(src, name), Some(15));
702    }
703
704    #[test]
705    fn str_offset_content_fallback_for_different_allocation() {
706        // "foo" is a separately owned String (not a sub-slice of the source),
707        // so pointer arithmetic fails. The fallback finds it by content.
708        let owned = "foo".to_string();
709        assert_eq!(str_offset("<?php foo", &owned), Some(6));
710    }
711
712    #[test]
713    fn str_offset_unrelated_content_returns_none() {
714        let owned = "bar".to_string();
715        assert_eq!(str_offset("<?php foo", &owned), None);
716    }
717
718    /// `file_imports()` must walk the AST at most once per `ParsedDoc` —
719    /// repeat calls (goto-definition, references, rename, and hover's
720    /// cross-file fallback all want the map for the same open file) should
721    /// hit the memoized `Arc`, not re-run `collect_file_imports`.
722    #[test]
723    fn file_imports_is_memoized_across_calls() {
724        let doc = ParsedDoc::parse("<?php\nuse App\\Foo;\nuse App\\Bar as B;\n".to_string());
725
726        let first = doc.file_imports();
727        assert_eq!(first.get("Foo").map(String::as_str), Some("App\\Foo"));
728        assert_eq!(first.get("B").map(String::as_str), Some("App\\Bar"));
729
730        let second = doc.file_imports();
731        assert!(
732            Arc::ptr_eq(&first, &second),
733            "repeat calls must share the memoized map, not recompute it"
734        );
735    }
736}