1use 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
13const POOL_CAP: usize = 8;
16
17struct BumpPool {
18 #[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
47struct 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
60pub 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 imports_cache: OnceLock<Arc<HashMap<String, String>>>,
87}
88
89impl Drop for ParsedDoc {
90 fn drop(&mut self) {
91 unsafe { ManuallyDrop::drop(&mut self.program) };
95 }
96}
97
98unsafe impl Send for ParsedDoc {}
100unsafe impl Sync for ParsedDoc {}
101
102impl ParsedDoc {
103 pub fn parse(source: impl Into<Arc<str>>) -> Self {
107 let source: Arc<str> = source.into();
108 let arena_box = BUMP_POOL.take();
110
111 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 #[inline]
140 pub fn program(&self) -> &Program<'_, '_> {
141 &self.program
142 }
143
144 #[inline]
146 pub fn source(&self) -> &str {
147 &self._source
148 }
149
150 #[inline]
156 pub fn source_arc(&self) -> Arc<str> {
157 self._source.clone()
158 }
159
160 pub fn line_starts(&self) -> &[u32] {
163 &self.line_starts
164 }
165
166 pub fn view(&self) -> SourceView<'_> {
168 SourceView {
169 source: self.source(),
170 line_starts: self.line_starts(),
171 }
172 }
173
174 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
190fn 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#[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 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 #[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 pub fn byte_of_position(self, pos: Position) -> u32 {
308 let line_idx = pos.line as usize;
309 let line_start = self.line_starts.get(line_idx).copied().unwrap_or(0) as usize;
310 let line_end = self
311 .line_starts
312 .get(line_idx + 1)
313 .map(|&s| (s as usize).saturating_sub(1))
314 .unwrap_or(self.source.len());
315 let raw = &self.source[line_start..line_end.min(self.source.len())];
316 let line = raw.strip_suffix('\r').unwrap_or(raw);
317 let mut col_utf16: u32 = 0;
318 let mut byte_in_line: usize = 0;
319 for ch in line.chars() {
320 if col_utf16 >= pos.character {
321 break;
322 }
323 col_utf16 += ch.len_utf16() as u32;
324 byte_in_line += ch.len_utf8();
325 }
326 (line_start + byte_in_line) as u32
327 }
328
329 pub fn range_of(self, span: Span) -> Range {
330 Range {
331 start: self.position_of(span.start),
332 end: self.position_of(span.end),
333 }
334 }
335
336 pub fn name_range(self, name: &str) -> Range {
337 let start = str_offset(self.source, name).unwrap_or(0);
338 Range {
339 start: self.position_of(start),
340 end: self.position_of(start + name.len() as u32),
341 }
342 }
343
344 pub fn name_range_in_span(self, name: &str, span: php_ast::Span) -> Range {
355 let s = span.start as usize;
356 let e = (span.end as usize).min(self.source.len());
357 let found = self
358 .source
359 .get(s..e)
360 .and_then(|slice| slice.find(name))
361 .map(|off| span.start + off as u32)
362 .or_else(|| str_offset(self.source, name));
363 match found {
364 Some(start) => Range {
365 start: self.position_of(start),
366 end: self.position_of(start + name.len() as u32),
367 },
368 None => {
369 let pos = self.position_of(span.start);
370 Range {
371 start: pos,
372 end: pos,
373 }
374 }
375 }
376 }
377
378 pub fn name_range_after_attrs(
387 self,
388 name: &str,
389 attributes: &[php_ast::Attribute<'_, '_>],
390 span: php_ast::Span,
391 ) -> Range {
392 let start = attributes.last().map(|a| a.span.end).unwrap_or(span.start);
393 self.name_range_in_span(name, php_ast::Span::new(start, span.end))
394 }
395}
396
397pub fn offset_to_position(source: &str, line_starts: &[u32], offset: u32) -> Position {
404 let offset_usize = (offset as usize).min(source.len());
405 let line = match line_starts.partition_point(|&s| s <= offset) {
407 0 => 0u32,
408 i => (i - 1) as u32,
409 };
410 let line_start = line_starts.get(line as usize).copied().unwrap_or(0) as usize;
411 let segment = &source[line_start..offset_usize];
412 let segment = segment.strip_suffix('\r').unwrap_or(segment);
414 let character = segment.chars().map(|c| c.len_utf16() as u32).sum::<u32>();
415 Position { line, character }
416}
417
418pub fn span_to_range(source: &str, line_starts: &[u32], span: Span) -> Range {
420 Range {
421 start: offset_to_position(source, line_starts, span.start),
422 end: offset_to_position(source, line_starts, span.end),
423 }
424}
425
426pub fn str_offset(source: &str, substr: &str) -> Option<u32> {
437 let src_ptr = source.as_ptr() as usize;
438 let sub_ptr = substr.as_ptr() as usize;
439 if sub_ptr >= src_ptr && sub_ptr + substr.len() <= src_ptr + source.len() {
440 return Some((sub_ptr - src_ptr) as u32);
441 }
442 let mut search_pos = 0;
446 while let Some(offset) = source[search_pos..].find(substr) {
447 let abs_offset = search_pos + offset;
448 let is_start_boundary = abs_offset == 0
449 || !source[..abs_offset]
450 .chars()
451 .last()
452 .map(|c| c.is_alphanumeric() || c == '_')
453 .unwrap_or(false);
454 let end_pos = abs_offset + substr.len();
455 let is_end_boundary = end_pos >= source.len()
456 || !source[end_pos..]
457 .chars()
458 .next()
459 .map(|c| c.is_alphanumeric() || c == '_')
460 .unwrap_or(false);
461
462 if is_start_boundary && is_end_boundary {
463 return Some(abs_offset as u32);
464 }
465
466 search_pos = abs_offset + 1;
467 }
468 None
469}
470
471pub fn name_range(source: &str, line_starts: &[u32], name: &str) -> Option<Range> {
473 let start = str_offset(source, name)?;
474 Some(Range {
475 start: offset_to_position(source, line_starts, start),
476 end: offset_to_position(source, line_starts, start + name.len() as u32),
477 })
478}
479
480pub fn str_offset_in_range(source: &str, span: Span, name: &str) -> Option<u32> {
483 let span_start = span.start as usize;
484 let span_end = span.end as usize;
485 if span_end > source.len() {
486 return None;
487 }
488 let span_text = &source[span_start..span_end];
489 let offset = str_offset(span_text, name)?;
490 Some(span_start as u32 + offset)
491}
492
493pub fn use_item_alias_and_fqn(item: &php_ast::UseItem<'_, '_>) -> (String, String) {
500 let fqn = item.name.to_string_repr().into_owned();
501 let alias = item
502 .alias
503 .map(|a| a.to_string())
504 .unwrap_or_else(|| crate::text::fqn_short_name(&fqn).to_string());
505 (alias, fqn)
506}
507
508pub fn format_type_hint(hint: &TypeHint<'_, '_>) -> String {
512 fmt_kind(&hint.kind)
513}
514
515fn fmt_kind(kind: &TypeHintKind<'_, '_>) -> String {
516 match kind {
517 TypeHintKind::Named(name) => name.to_string_repr().to_string(),
518 TypeHintKind::Keyword(builtin, _) => builtin.as_str().to_string(),
519 TypeHintKind::Nullable(inner) => format!("?{}", format_type_hint(inner)),
520 TypeHintKind::Union(types) => types
521 .iter()
522 .map(format_type_hint)
523 .collect::<Vec<_>>()
524 .join("|"),
525 TypeHintKind::Intersection(types) => types
526 .iter()
527 .map(format_type_hint)
528 .collect::<Vec<_>>()
529 .join("&"),
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn parses_empty_source() {
539 let doc = ParsedDoc::parse("<?php".to_string());
540 assert!(doc.errors.is_empty());
541 assert!(doc.program().stmts.is_empty());
542 }
543
544 #[test]
545 fn parses_function() {
546 let doc = ParsedDoc::parse("<?php\nfunction foo() {}".to_string());
547 assert_eq!(doc.program().stmts.len(), 1);
548 match &doc.program().stmts[0].kind {
549 php_ast::StmtKind::Function(f) => assert_eq!(f.name.as_str(), Some("foo")),
550 other => panic!("expected a function declaration, got {other:?}"),
551 }
552 }
553
554 #[test]
555 fn offset_to_position_first_line() {
556 let src = "<?php\nfoo";
557 let doc = ParsedDoc::parse(src.to_string());
558 assert_eq!(
559 offset_to_position(src, doc.line_starts(), 0),
560 Position {
561 line: 0,
562 character: 0
563 }
564 );
565 }
566
567 #[test]
568 fn offset_to_position_second_line() {
569 let src = "<?php\nfoo";
571 let doc = ParsedDoc::parse(src.to_string());
572 assert_eq!(
573 offset_to_position(src, doc.line_starts(), 6),
574 Position {
575 line: 1,
576 character: 0
577 }
578 );
579 }
580
581 #[test]
582 fn offset_to_position_multibyte_utf16() {
583 let src = "a\u{1F600}b";
588 let doc = ParsedDoc::parse(src.to_string());
589 assert_eq!(
590 offset_to_position(src, doc.line_starts(), 5), Position {
592 line: 0,
593 character: 3
594 } );
596 }
597
598 #[test]
599 fn offset_to_position_crlf_start_of_line() {
600 let src = "foo\r\nbar";
603 let doc = ParsedDoc::parse(src.to_string());
604 assert_eq!(
605 offset_to_position(src, doc.line_starts(), 5), Position {
607 line: 1,
608 character: 0
609 }
610 );
611 }
612
613 #[test]
614 fn offset_to_position_crlf_does_not_count_cr_in_column() {
615 let src = "foo\r\nbar";
618 let doc = ParsedDoc::parse(src.to_string());
619 assert_eq!(
620 offset_to_position(src, doc.line_starts(), 3), Position {
622 line: 0,
623 character: 3
624 }
625 );
626 }
627
628 #[test]
629 fn offset_to_position_crlf_multiline() {
630 let src = "a\r\nb\r\nc";
633 let doc = ParsedDoc::parse(src.to_string());
634 assert_eq!(
635 offset_to_position(src, doc.line_starts(), 6), Position {
637 line: 2,
638 character: 0
639 }
640 );
641 assert_eq!(
642 offset_to_position(src, doc.line_starts(), 3), Position {
644 line: 1,
645 character: 0
646 }
647 );
648 }
649
650 #[test]
651 fn positions_of_offsets_matches_single_lookup() {
652 let src = "<?php\r\n$a = \"café\";\r\n$b = \"😀\";\r\n";
653 let doc = ParsedDoc::parse(src.to_string());
654 let offsets = vec![0, 6, 10, 18, 23, 28, 32, src.len() as u32];
655 let batched = doc.view().positions_of_offsets(&offsets);
656 let expected: Vec<Position> = offsets
657 .iter()
658 .map(|&offset| offset_to_position(src, doc.line_starts(), offset))
659 .collect();
660 assert_eq!(batched, expected);
661 }
662
663 #[test]
664 fn positions_of_offsets_preserves_input_order() {
665 let src = "a\nbb\nccc";
666 let doc = ParsedDoc::parse(src.to_string());
667 let offsets = vec![5, 0, 3, 1];
668 assert_eq!(
669 doc.view().positions_of_offsets(&offsets),
670 vec![
671 Position {
672 line: 2,
673 character: 0,
674 },
675 Position {
676 line: 0,
677 character: 0,
678 },
679 Position {
680 line: 1,
681 character: 1,
682 },
683 Position {
684 line: 0,
685 character: 1,
686 },
687 ]
688 );
689 }
690
691 #[test]
692 fn str_offset_finds_substr() {
693 let src = "<?php\nfunction foo() {}";
694 let name = &src[15..18]; assert_eq!(str_offset(src, name), Some(15));
696 }
697
698 #[test]
699 fn str_offset_content_fallback_for_different_allocation() {
700 let owned = "foo".to_string();
703 assert_eq!(str_offset("<?php foo", &owned), Some(6));
704 }
705
706 #[test]
707 fn str_offset_unrelated_content_returns_none() {
708 let owned = "bar".to_string();
709 assert_eq!(str_offset("<?php foo", &owned), None);
710 }
711
712 #[test]
717 fn file_imports_is_memoized_across_calls() {
718 let doc = ParsedDoc::parse("<?php\nuse App\\Foo;\nuse App\\Bar as B;\n".to_string());
719
720 let first = doc.file_imports();
721 assert_eq!(first.get("Foo").map(String::as_str), Some("App\\Foo"));
722 assert_eq!(first.get("B").map(String::as_str), Some("App\\Bar"));
723
724 let second = doc.file_imports();
725 assert!(
726 Arc::ptr_eq(&first, &second),
727 "repeat calls must share the memoized map, not recompute it"
728 );
729 }
730}