1use std::collections::HashMap;
2use std::ops::ControlFlow;
3
4use php_ast::visitor::{Visitor, walk_expr};
5use php_ast::{Expr, ExprKind};
6use tower_lsp_server::ls_types::{
7 DocumentHighlightKind, Position, Range, TextEdit, Uri, WorkspaceEdit,
8};
9
10use crate::document::ast::ParsedDoc;
11use crate::lang::is_php_keyword;
12use crate::navigation::walk::collect_var_refs_in_scope;
13use crate::text::utf16_code_units;
14
15pub fn narrow_range_to_word(source: &str, range: Range, word: &str) -> Option<Range> {
27 if word.is_empty() || range.start.line != range.end.line {
28 return None;
29 }
30 let line = source.lines().nth(range.start.line as usize)?;
31 let byte_at_col = |col: u32| -> Option<usize> {
33 let mut u16s: u32 = 0;
34 if col == 0 {
35 return Some(0);
36 }
37 for (i, ch) in line.char_indices() {
38 if u16s == col {
39 return Some(i);
40 }
41 u16s += ch.len_utf16() as u32;
42 }
43 (u16s >= col).then_some(line.len())
44 };
45 let start_b = byte_at_col(range.start.character)?;
46 let end_b = byte_at_col(range.end.character)?;
47 let slice = line.get(start_b..end_b)?;
48 if slice == word {
49 return Some(range);
50 }
51
52 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
53 let mut found: Option<usize> = None;
54 let mut search = 0usize;
55 while let Some(pos) = slice[search..].find(word) {
56 let abs = search + pos;
57 let before_ok = abs == 0 || !slice[..abs].chars().next_back().is_some_and(is_word_char);
58 let after = abs + word.len();
59 let after_ok =
60 after >= slice.len() || !slice[after..].chars().next().is_some_and(is_word_char);
61 if before_ok && after_ok {
62 found = Some(abs);
63 }
64 search = after.max(abs + 1);
65 }
66 let off = found?;
67
68 let new_start = range.start.character + utf16_code_units(&slice[..off]);
69 Some(Range {
70 start: Position {
71 line: range.start.line,
72 character: new_start,
73 },
74 end: Position {
75 line: range.start.line,
76 character: new_start + utf16_code_units(word),
77 },
78 })
79}
80
81pub fn sort_and_dedup_edits(edits: &mut Vec<TextEdit>) {
84 edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
85 edits.dedup_by(|a, b| a.range == b.range);
86}
87
88pub fn prepare_rename(doc: &ParsedDoc, position: Position) -> Option<Range> {
91 use crate::text::word_at_position;
92 let source = doc.source();
93 let word = word_at_position(source, position)?;
94 if word.contains('\\') {
95 return None;
96 }
97 if is_php_keyword(&word) && !is_member_name_position(doc, position) {
102 return None;
103 }
104 if is_superglobal(&word) {
107 return None;
108 }
109 let line = source.lines().nth(position.line as usize)?;
110 let col = position.character as usize;
111 let chars: Vec<char> = line.chars().collect();
112 let is_word = |c: char| c.is_alphanumeric() || c == '_';
116
117 let mut utf16_col = 0usize;
119 let mut char_idx = 0usize;
120 for (i, ch) in chars.iter().enumerate() {
121 let char_width = ch.len_utf16();
123 if utf16_col + char_width > col {
124 char_idx = i;
125 break;
126 }
127 utf16_col += char_width;
128 char_idx = i + 1;
129 }
130
131 let mut left = char_idx;
133 while left > 0 && is_word(chars[left - 1]) {
134 left -= 1;
135 }
136 if chars.get(left) == Some(&'$') {
137 left += 1;
138 }
139
140 let bare_word = word.trim_start_matches('$');
141 let start_utf16: u32 = chars[..left].iter().map(|c| c.len_utf16() as u32).sum();
142 let end_utf16: u32 = start_utf16 + utf16_code_units(bare_word);
143 Some(Range {
144 start: Position {
145 line: position.line,
146 character: start_utf16,
147 },
148 end: Position {
149 line: position.line,
150 character: end_utf16,
151 },
152 })
153}
154
155fn is_member_name_position(doc: &ParsedDoc, position: Position) -> bool {
170 use crate::text::word_range_at;
171 let source = doc.source();
172 let Some(range) = word_range_at(source, position) else {
173 return false;
174 };
175 let offset = doc.view().byte_of_position(range.start);
176 if is_member_access_name(doc, offset) {
177 return true;
178 }
179 let Some(line) = source.lines().nth(range.start.line as usize) else {
180 return false;
181 };
182 let before = utf16_prefix(line, range.start.character).trim_end();
183 match before.strip_suffix("function") {
184 Some(rest) => rest
185 .chars()
186 .next_back()
187 .is_none_or(|c| !c.is_alphanumeric() && c != '_'),
188 None => false,
189 }
190}
191
192fn is_member_access_name(doc: &ParsedDoc, offset: u32) -> bool {
195 struct Finder {
196 offset: u32,
197 found: bool,
198 }
199
200 fn at(span: php_ast::Span, offset: u32) -> bool {
201 span.start <= offset && offset < span.end
202 }
203
204 impl<'arena, 'src> Visitor<'arena, 'src> for Finder {
205 fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
206 if self.found {
207 return ControlFlow::Break(());
208 }
209 let hit = match &expr.kind {
210 ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => {
211 at(m.method.span, self.offset)
212 }
213 ExprKind::StaticMethodCall(s) => at(s.method.span, self.offset),
214 ExprKind::StaticDynMethodCall(s) => at(s.method.span, self.offset),
215 ExprKind::PropertyAccess(p) | ExprKind::NullsafePropertyAccess(p) => {
216 at(p.property.span, self.offset)
217 }
218 ExprKind::StaticPropertyAccess(s) | ExprKind::ClassConstAccess(s) => {
219 at(s.member.span, self.offset)
220 }
221 _ => false,
222 };
223 if hit {
224 self.found = true;
225 return ControlFlow::Break(());
226 }
227 walk_expr(self, expr)
228 }
229 }
230
231 let mut finder = Finder {
232 offset,
233 found: false,
234 };
235 for stmt in doc.program().stmts.iter() {
236 let _ = finder.visit_stmt(stmt);
237 }
238 finder.found
239}
240
241fn utf16_prefix(line: &str, col: u32) -> &str {
243 let mut u16s = 0u32;
244 for (i, ch) in line.char_indices() {
245 if u16s >= col {
246 return &line[..i];
247 }
248 u16s += ch.len_utf16() as u32;
249 }
250 line
251}
252
253pub(crate) fn is_superglobal(word: &str) -> bool {
254 matches!(
255 word,
256 "$_GET"
257 | "$_POST"
258 | "$_REQUEST"
259 | "$_FILES"
260 | "$_COOKIE"
261 | "$_SESSION"
262 | "$_SERVER"
263 | "$_ENV"
264 | "$GLOBALS"
265 | "$this"
266 )
267}
268
269pub struct VariableRename {
272 pub edit: WorkspaceEdit,
273 pub named_arg_callee: Option<Position>,
276}
277
278pub fn rename_variable(
281 var_name: &str,
282 new_name: &str,
283 uri: &Uri,
284 doc: &ParsedDoc,
285 position: Position,
286) -> VariableRename {
287 let bare = var_name.trim_start_matches('$');
288 let new_bare = new_name.trim_start_matches('$');
289 let new_text = format!("${new_bare}");
290
291 let stmts = &doc.program().stmts;
292 let sv = doc.view();
293 let byte_off = sv.byte_of_position(position) as usize;
294
295 let mut spans = Vec::new();
296 collect_var_refs_in_scope(stmts, bare, byte_off, &mut spans);
297 let owner = param_owner(doc, bare, &spans);
298
299 let mut seen = std::collections::HashSet::new();
300 let mut edits: Vec<TextEdit> = spans
301 .into_iter()
302 .filter_map(|(span, _)| {
303 let span = variable_token_span(doc.source(), span, bare);
304 let start = sv.position_of(span.start);
305 let end = sv.position_of(span.end);
306 seen.insert((start.line, start.character))
307 .then_some(TextEdit {
308 range: Range { start, end },
309 new_text: new_text.clone(),
310 })
311 })
312 .collect();
313 if let Some(comment) = owner.as_ref().and_then(|o| o.doc_comment) {
314 edits.extend(
315 docblock_param_spans(comment, bare)
316 .into_iter()
317 .map(|span| TextEdit {
318 range: sv.range_of(span),
319 new_text: new_text.clone(),
320 }),
321 );
322 }
323 edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
324
325 let mut changes = HashMap::new();
326 if !edits.is_empty() {
327 changes.insert(uri.clone(), edits);
328 }
329
330 VariableRename {
331 edit: WorkspaceEdit {
332 changes: if changes.is_empty() {
333 None
334 } else {
335 Some(changes)
336 },
337 ..Default::default()
338 },
339 named_arg_callee: owner.and_then(|o| o.callee),
340 }
341}
342
343struct ParamOwner<'src> {
344 doc_comment: Option<php_ast::Comment<'src>>,
345 callee: Option<Position>,
346}
347
348fn param_owner<'d>(
351 doc: &'d ParsedDoc,
352 name: &str,
353 spans: &[(php_ast::Span, DocumentHighlightKind)],
354) -> Option<ParamOwner<'d>> {
355 struct Finder<'a, 'src> {
356 sv: crate::document::ast::SourceView<'a>,
357 name: &'a str,
358 spans: &'a [(php_ast::Span, DocumentHighlightKind)],
359 class: Option<(&'src str, php_ast::Span)>,
360 found: Option<ParamOwner<'src>>,
361 }
362
363 impl<'src> Finder<'_, 'src> {
364 fn declares(&self, params: &[php_ast::Param<'_, 'src>]) -> bool {
365 params
366 .iter()
367 .any(|p| p.name == self.name && self.spans.iter().any(|(s, _)| *s == p.span))
368 }
369
370 fn method(&mut self, m: &php_ast::MethodDecl<'_, 'src>, span: php_ast::Span) -> bool {
371 if !self.declares(&m.params) {
372 return false;
373 }
374 let callee = if m.name.as_str() == Some("__construct") {
375 self.class
376 .map(|(name, span)| self.sv.name_range_in_span(name, span).start)
377 } else {
378 m.name.as_str().map(|name| {
379 self.sv
380 .name_range_after_attrs(name, &m.attributes, span)
381 .start
382 })
383 };
384 self.found = Some(ParamOwner {
385 doc_comment: m.doc_comment,
386 callee,
387 });
388 true
389 }
390 }
391
392 impl<'arena, 'src> Visitor<'arena, 'src> for Finder<'_, 'src> {
393 fn visit_stmt(&mut self, stmt: &php_ast::Stmt<'arena, 'src>) -> ControlFlow<()> {
394 match &stmt.kind {
395 php_ast::StmtKind::Function(f) if self.declares(&f.params) => {
396 self.found = Some(ParamOwner {
397 doc_comment: f.doc_comment,
398 callee: f.name.as_str().map(|name| {
399 self.sv
400 .name_range_after_attrs(name, &f.attributes, stmt.span)
401 .start
402 }),
403 });
404 return ControlFlow::Break(());
405 }
406 php_ast::StmtKind::Class(c) => {
407 let outer = self.class;
408 self.class = c
409 .name
410 .and_then(|n| n.as_str())
411 .map(|name| (name, stmt.span));
412 let flow = php_ast::visitor::walk_stmt(self, stmt);
413 self.class = outer;
414 return flow;
415 }
416 php_ast::StmtKind::Interface(_)
417 | php_ast::StmtKind::Trait(_)
418 | php_ast::StmtKind::Enum(_) => {
419 let outer = self.class.take();
420 let flow = php_ast::visitor::walk_stmt(self, stmt);
421 self.class = outer;
422 return flow;
423 }
424 _ => {}
425 }
426 php_ast::visitor::walk_stmt(self, stmt)
427 }
428
429 fn visit_class_member(
430 &mut self,
431 member: &php_ast::ClassMember<'arena, 'src>,
432 ) -> ControlFlow<()> {
433 if let php_ast::ClassMemberKind::Method(m) = &member.kind
434 && self.method(m, member.span)
435 {
436 return ControlFlow::Break(());
437 }
438 php_ast::visitor::walk_class_member(self, member)
439 }
440
441 fn visit_enum_member(
442 &mut self,
443 member: &php_ast::EnumMember<'arena, 'src>,
444 ) -> ControlFlow<()> {
445 if let php_ast::EnumMemberKind::Method(m) = &member.kind
446 && self.method(m, member.span)
447 {
448 return ControlFlow::Break(());
449 }
450 php_ast::visitor::walk_enum_member(self, member)
451 }
452
453 fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
454 let declares = match &expr.kind {
455 ExprKind::Closure(c) => self.declares(&c.params),
456 ExprKind::ArrowFunction(a) => self.declares(&a.params),
457 _ => false,
458 };
459 if declares {
460 self.found = Some(ParamOwner {
461 doc_comment: None,
462 callee: None,
463 });
464 return ControlFlow::Break(());
465 }
466 walk_expr(self, expr)
467 }
468 }
469
470 let mut finder = Finder {
471 sv: doc.view(),
472 name,
473 spans,
474 class: None,
475 found: None,
476 };
477 let _ = finder.visit_program(doc.program());
478 finder.found
479}
480
481fn docblock_param_spans(comment: php_ast::Comment<'_>, name: &str) -> Vec<php_ast::Span> {
484 let needle = format!("${name}");
485 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
486 let mut out = Vec::new();
487 let mut line_start = 0usize;
488 for line in comment.text.split_inclusive('\n') {
489 let tag = line.find("@param").or_else(|| {
490 line.find("@psalm-param")
491 .or_else(|| line.find("@phpstan-param"))
492 });
493 if let Some(tag) = tag {
494 let mut from = tag;
495 while let Some(pos) = line[from..].find(&needle) {
496 let at = from + pos;
497 let end = at + needle.len();
498 if !line[end..].chars().next().is_some_and(is_word_char) {
499 let start = comment.span.start + (line_start + at) as u32;
500 out.push(php_ast::Span::new(start, start + needle.len() as u32));
501 break;
502 }
503 from = end;
504 }
505 }
506 line_start += line.len();
507 }
508 out
509}
510
511pub fn named_arg_ranges(doc: &ParsedDoc, callee_ranges: &[Range], name: &str) -> Vec<Range> {
514 struct Finder<'a> {
515 callee_offsets: Vec<u32>,
516 name: &'a str,
517 out: Vec<php_ast::Span>,
518 }
519
520 impl<'arena, 'src> Visitor<'arena, 'src> for Finder<'_> {
521 fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
522 let call = match &expr.kind {
523 ExprKind::FunctionCall(c) => Some((c.name.span, &c.args)),
524 ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => {
525 Some((m.method.span, &m.args))
526 }
527 ExprKind::StaticMethodCall(s) => Some((s.method.span, &s.args)),
528 ExprKind::New(n) => Some((n.class.span, &n.args)),
529 _ => None,
530 };
531 if let Some((callee, args)) = call
532 && self
533 .callee_offsets
534 .iter()
535 .any(|&off| callee.start <= off && off < callee.end)
536 {
537 self.out.extend(
538 args.iter()
539 .filter_map(|a| a.name.as_ref())
540 .filter(|n| n.to_string_repr() == self.name)
541 .map(|n| n.span()),
542 );
543 }
544 walk_expr(self, expr)
545 }
546 }
547
548 let sv = doc.view();
549 let mut finder = Finder {
550 callee_offsets: callee_ranges
551 .iter()
552 .map(|r| sv.byte_of_position(r.start))
553 .collect(),
554 name,
555 out: Vec::new(),
556 };
557 let _ = finder.visit_program(doc.program());
558 finder
559 .out
560 .into_iter()
561 .map(|span| sv.range_of(span))
562 .collect()
563}
564
565pub fn promoted_param_class(doc: &ParsedDoc, ranges: &[Range], name: &str) -> Option<Position> {
568 struct Finder<'a> {
569 source: &'a str,
570 sv: crate::document::ast::SourceView<'a>,
571 offsets: Vec<u32>,
572 name: &'a str,
573 found: Option<Position>,
574 }
575
576 impl<'arena, 'src> Visitor<'arena, 'src> for Finder<'_> {
577 fn visit_stmt(&mut self, stmt: &php_ast::Stmt<'arena, 'src>) -> ControlFlow<()> {
578 if let php_ast::StmtKind::Class(c) = &stmt.kind
579 && let Some(class_name) = c.name.and_then(|n| n.as_str())
580 {
581 let promotes = c.body.members.iter().any(|member| {
582 let php_ast::ClassMemberKind::Method(m) = &member.kind else {
583 return false;
584 };
585 m.name == "__construct"
586 && m.params.iter().any(|p| {
587 (p.visibility.is_some() || p.is_readonly)
588 && p.name == self.name
589 && self.offsets.iter().any(|&off| {
590 let token = variable_token_span(self.source, p.span, self.name);
591 token.start <= off && off < token.end
592 })
593 })
594 });
595 if promotes {
596 self.found = Some(self.sv.name_range_in_span(class_name, stmt.span).start);
597 return ControlFlow::Break(());
598 }
599 }
600 php_ast::visitor::walk_stmt(self, stmt)
601 }
602 }
603
604 let sv = doc.view();
605 let mut finder = Finder {
606 source: doc.source(),
607 sv,
608 offsets: ranges
609 .iter()
610 .map(|r| sv.byte_of_position(r.start))
611 .collect(),
612 name,
613 found: None,
614 };
615 let _ = finder.visit_program(doc.program());
616 finder.found
617}
618
619fn variable_token_span(source: &str, span: php_ast::Span, bare: &str) -> php_ast::Span {
621 let needle = format!("${bare}");
622 let Some(text) = source.get(span.start as usize..span.end as usize) else {
623 return span;
624 };
625 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
626 let mut from = 0;
627 while let Some(pos) = text[from..].find(&needle) {
628 let at = from + pos;
629 let end = at + needle.len();
630 if !text[end..].chars().next().is_some_and(is_word_char) {
631 let start = span.start + at as u32;
632 return php_ast::Span::new(start, start + needle.len() as u32);
633 }
634 from = end;
635 }
636 span
637}