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 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 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 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
400pub fn offset_to_position(source: &str, line_starts: &[u32], offset: u32) -> Position {
407 let offset_usize = (offset as usize).min(source.len());
408 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 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
421pub 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
429pub 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 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
474pub 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
483pub 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
496pub 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
511pub 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 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 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), Position {
606 line: 0,
607 character: 3
608 } );
610 }
611
612 #[test]
613 fn offset_to_position_crlf_start_of_line() {
614 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), 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 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), Position {
636 line: 0,
637 character: 3
638 }
639 );
640 }
641
642 #[test]
643 fn offset_to_position_crlf_multiline() {
644 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), Position {
651 line: 2,
652 character: 0
653 }
654 );
655 assert_eq!(
656 offset_to_position(src, doc.line_starts(), 3), 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]; assert_eq!(str_offset(src, name), Some(15));
710 }
711
712 #[test]
713 fn str_offset_content_fallback_for_different_allocation() {
714 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 #[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}