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/// Convert a `Span` (byte-offset pair) to an LSP `Range`.
422pub fn span_to_range(source: &str, line_starts: &[u32], span: Span) -> Range {
423    Range {
424        start: offset_to_position(source, line_starts, span.start),
425        end: offset_to_position(source, line_starts, span.end),
426    }
427}
428
429/// Return the byte offset of `substr` within `source`, or None if not found.
430///
431/// Uses pointer arithmetic when `substr` is a true sub-slice of `source`
432/// (i.e. arena-allocated names pointing into the same backing string).
433/// Falls back to a content search when the pointers differ — this handles
434/// tests and callers that pass a differently-allocated copy of the source.
435///
436/// When falling back, prefers matches that are preceded and followed by
437/// non-identifier characters (word boundaries), to avoid matching name parts
438/// (e.g., finding "B" in "Base" when searching for a class named "B").
439pub fn str_offset(source: &str, substr: &str) -> Option<u32> {
440    let src_ptr = source.as_ptr() as usize;
441    let sub_ptr = substr.as_ptr() as usize;
442    if sub_ptr >= src_ptr && sub_ptr + substr.len() <= src_ptr + source.len() {
443        return Some((sub_ptr - src_ptr) as u32);
444    }
445    // Fallback: locate by content with word-boundary preference.
446    // When pointer matching fails, prefer matches where substr is surrounded by
447    // non-identifier characters to avoid matching partial names (e.g., "B" in "Base").
448    let mut search_pos = 0;
449    while let Some(offset) = source[search_pos..].find(substr) {
450        let abs_offset = search_pos + offset;
451        let is_start_boundary = abs_offset == 0
452            || !source[..abs_offset]
453                .chars()
454                .last()
455                .map(|c| c.is_alphanumeric() || c == '_')
456                .unwrap_or(false);
457        let end_pos = abs_offset + substr.len();
458        let is_end_boundary = end_pos >= source.len()
459            || !source[end_pos..]
460                .chars()
461                .next()
462                .map(|c| c.is_alphanumeric() || c == '_')
463                .unwrap_or(false);
464
465        if is_start_boundary && is_end_boundary {
466            return Some(abs_offset as u32);
467        }
468
469        search_pos = abs_offset + 1;
470    }
471    None
472}
473
474/// Build an LSP `Range` for a name that is a sub-slice of `source`, or None if not found.
475pub fn name_range(source: &str, line_starts: &[u32], name: &str) -> Option<Range> {
476    let start = str_offset(source, name)?;
477    Some(Range {
478        start: offset_to_position(source, line_starts, start),
479        end: offset_to_position(source, line_starts, start + name.len() as u32),
480    })
481}
482
483/// Find a name within a specific byte range of the source, with word-boundary matching.
484/// Returns the absolute byte offset if found, None otherwise.
485pub fn str_offset_in_range(source: &str, span: Span, name: &str) -> Option<u32> {
486    let span_start = span.start as usize;
487    let span_end = span.end as usize;
488    if span_end > source.len() {
489        return None;
490    }
491    let span_text = &source[span_start..span_end];
492    let offset = str_offset(span_text, name)?;
493    Some(span_start as u32 + offset)
494}
495
496/// The `(alias-or-short-name, FQN)` pair for one `use` import item: the
497/// import's local name (an explicit alias when present, otherwise the last
498/// segment of the imported FQN) paired with its fully-qualified name.
499/// Shared by `navigation::references`'s import-map builder and
500/// `index::file_index`'s persistent-index extraction, which otherwise
501/// duplicated this exact per-item computation.
502pub fn use_item_alias_and_fqn(item: &php_ast::UseItem<'_, '_>) -> (String, String) {
503    let fqn = item.name.to_string_repr().into_owned();
504    let alias = item
505        .alias
506        .map(|a| a.to_string())
507        .unwrap_or_else(|| crate::text::fqn_short_name(&fqn).to_string());
508    (alias, fqn)
509}
510
511// ── TypeHint formatting ────────────────────────────────────────────────────────
512
513/// Format a `TypeHint` as a PHP type string, e.g. `?int`, `string|null`.
514pub fn format_type_hint(hint: &TypeHint<'_, '_>) -> String {
515    fmt_kind(&hint.kind)
516}
517
518fn fmt_kind(kind: &TypeHintKind<'_, '_>) -> String {
519    match kind {
520        TypeHintKind::Named(name) => name.to_string_repr().to_string(),
521        TypeHintKind::Keyword(builtin, _) => builtin.as_str().to_string(),
522        TypeHintKind::Nullable(inner) => format!("?{}", format_type_hint(inner)),
523        TypeHintKind::Union(types) => types
524            .iter()
525            .map(format_type_hint)
526            .collect::<Vec<_>>()
527            .join("|"),
528        TypeHintKind::Intersection(types) => types
529            .iter()
530            .map(format_type_hint)
531            .collect::<Vec<_>>()
532            .join("&"),
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn byte_of_position_past_last_line_clamps_to_end() {
542        let src = "<?php\nfoo";
543        let doc = ParsedDoc::parse(src.to_string());
544        let pos = Position {
545            line: 99,
546            character: 0,
547        };
548        assert_eq!(doc.view().byte_of_position(pos), src.len() as u32);
549    }
550
551    #[test]
552    fn parses_empty_source() {
553        let doc = ParsedDoc::parse("<?php".to_string());
554        assert!(doc.errors.is_empty());
555        assert!(doc.program().stmts.is_empty());
556    }
557
558    #[test]
559    fn parses_function() {
560        let doc = ParsedDoc::parse("<?php\nfunction foo() {}".to_string());
561        assert_eq!(doc.program().stmts.len(), 1);
562        match &doc.program().stmts[0].kind {
563            php_ast::StmtKind::Function(f) => assert_eq!(f.name.as_str(), Some("foo")),
564            other => panic!("expected a function declaration, got {other:?}"),
565        }
566    }
567
568    #[test]
569    fn offset_to_position_first_line() {
570        let src = "<?php\nfoo";
571        let doc = ParsedDoc::parse(src.to_string());
572        assert_eq!(
573            offset_to_position(src, doc.line_starts(), 0),
574            Position {
575                line: 0,
576                character: 0
577            }
578        );
579    }
580
581    #[test]
582    fn offset_to_position_second_line() {
583        // "<?php\n" — offset 6 is start of line 1
584        let src = "<?php\nfoo";
585        let doc = ParsedDoc::parse(src.to_string());
586        assert_eq!(
587            offset_to_position(src, doc.line_starts(), 6),
588            Position {
589                line: 1,
590                character: 0
591            }
592        );
593    }
594
595    #[test]
596    fn offset_to_position_multibyte_utf16() {
597        // "é" is U+00E9: 2 UTF-8 bytes, 1 UTF-16 code unit.
598        // "😀" is U+1F600: 4 UTF-8 bytes, 2 UTF-16 code units.
599        // source: "a😀b" — byte offsets: a=0, 😀=1..5, b=5
600        // UTF-16:            a=col 0, 😀=col 1..3, b=col 3
601        let src = "a\u{1F600}b";
602        let doc = ParsedDoc::parse(src.to_string());
603        assert_eq!(
604            offset_to_position(src, doc.line_starts(), 5), // byte offset of 'b'
605            Position {
606                line: 0,
607                character: 3
608            }  // UTF-16 col 3
609        );
610    }
611
612    #[test]
613    fn offset_to_position_crlf_start_of_line() {
614        // CRLF: offset pointing to first char of line 1 must give character=0.
615        // "foo\r\nbar": f=0 o=1 o=2 \r=3 \n=4 b=5 a=6 r=7
616        let src = "foo\r\nbar";
617        let doc = ParsedDoc::parse(src.to_string());
618        assert_eq!(
619            offset_to_position(src, doc.line_starts(), 5), // 'b'
620            Position {
621                line: 1,
622                character: 0
623            }
624        );
625    }
626
627    #[test]
628    fn offset_to_position_crlf_does_not_count_cr_in_column() {
629        // Offset pointing to the \r itself must not count it as a column.
630        // "foo\r\nbar": the \r is at offset 3, column must be 3 (length of "foo").
631        let src = "foo\r\nbar";
632        let doc = ParsedDoc::parse(src.to_string());
633        assert_eq!(
634            offset_to_position(src, doc.line_starts(), 3), // '\r'
635            Position {
636                line: 0,
637                character: 3
638            }
639        );
640    }
641
642    #[test]
643    fn offset_to_position_crlf_multiline() {
644        // Multiple CRLF lines: columns must not accumulate stray \r counts.
645        // "a\r\nb\r\nc": a=0 \r=1 \n=2 b=3 \r=4 \n=5 c=6
646        let src = "a\r\nb\r\nc";
647        let doc = ParsedDoc::parse(src.to_string());
648        assert_eq!(
649            offset_to_position(src, doc.line_starts(), 6), // 'c'
650            Position {
651                line: 2,
652                character: 0
653            }
654        );
655        assert_eq!(
656            offset_to_position(src, doc.line_starts(), 3), // 'b'
657            Position {
658                line: 1,
659                character: 0
660            }
661        );
662    }
663
664    #[test]
665    fn positions_of_offsets_matches_single_lookup() {
666        let src = "<?php\r\n$a = \"café\";\r\n$b = \"😀\";\r\n";
667        let doc = ParsedDoc::parse(src.to_string());
668        let offsets = vec![0, 6, 10, 18, 23, 28, 32, src.len() as u32];
669        let batched = doc.view().positions_of_offsets(&offsets);
670        let expected: Vec<Position> = offsets
671            .iter()
672            .map(|&offset| offset_to_position(src, doc.line_starts(), offset))
673            .collect();
674        assert_eq!(batched, expected);
675    }
676
677    #[test]
678    fn positions_of_offsets_preserves_input_order() {
679        let src = "a\nbb\nccc";
680        let doc = ParsedDoc::parse(src.to_string());
681        let offsets = vec![5, 0, 3, 1];
682        assert_eq!(
683            doc.view().positions_of_offsets(&offsets),
684            vec![
685                Position {
686                    line: 2,
687                    character: 0,
688                },
689                Position {
690                    line: 0,
691                    character: 0,
692                },
693                Position {
694                    line: 1,
695                    character: 1,
696                },
697                Position {
698                    line: 0,
699                    character: 1,
700                },
701            ]
702        );
703    }
704
705    #[test]
706    fn str_offset_finds_substr() {
707        let src = "<?php\nfunction foo() {}";
708        let name = &src[15..18]; // "foo"
709        assert_eq!(str_offset(src, name), Some(15));
710    }
711
712    #[test]
713    fn str_offset_content_fallback_for_different_allocation() {
714        // "foo" is a separately owned String (not a sub-slice of the source),
715        // so pointer arithmetic fails. The fallback finds it by content.
716        let owned = "foo".to_string();
717        assert_eq!(str_offset("<?php foo", &owned), Some(6));
718    }
719
720    #[test]
721    fn str_offset_unrelated_content_returns_none() {
722        let owned = "bar".to_string();
723        assert_eq!(str_offset("<?php foo", &owned), None);
724    }
725
726    /// `file_imports()` must walk the AST at most once per `ParsedDoc` —
727    /// repeat calls (goto-definition, references, rename, and hover's
728    /// cross-file fallback all want the map for the same open file) should
729    /// hit the memoized `Arc`, not re-run `collect_file_imports`.
730    #[test]
731    fn file_imports_is_memoized_across_calls() {
732        let doc = ParsedDoc::parse("<?php\nuse App\\Foo;\nuse App\\Bar as B;\n".to_string());
733
734        let first = doc.file_imports();
735        assert_eq!(first.get("Foo").map(String::as_str), Some("App\\Foo"));
736        assert_eq!(first.get("B").map(String::as_str), Some("App\\Bar"));
737
738        let second = doc.file_imports();
739        assert!(
740            Arc::ptr_eq(&first, &second),
741            "repeat calls must share the memoized map, not recompute it"
742        );
743    }
744}