Skip to content

Commit 4cf1b10

Browse files
committed
Add Replace FQCN with import code
1 parent 9402e73 commit 4cf1b10

5 files changed

Lines changed: 224 additions & 4 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@
77
sandbox.php
88
.cargo
99
/.phpantom.toml
10+
examples/laravel/.codelite/laravel.session
11+
.codelite/
12+
examples/laravel/.codelite/

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- **Blade template support.** Completion, hover, go-to-definition, diagnostics, semantic tokens, and inlay hints work inside `.blade.php` files. (thanks [@MingJen](https://github.com/MingJen))
1313
- **Blade keyword highlighting.** Blade directives, echo delimiters, PHP keywords, cast types, comments, and PHPDoc tags inside `.blade.php` files now receive semantic tokens for proper syntax coloring.
1414
- **Blade view directive navigation.** Go-to-definition works on view names inside Blade directives (`@include`, `@extends`, `@includeIf`, `@includeWhen`, `@includeUnless`, `@includeFirst`, `@component`, `@each`), jumping to the referenced template file.
15+
- **Replace FQCN with import.** A refactoring code action on any fully-qualified class name (`\Foo\Bar`) inserts a `use` statement and replaces the inline reference with the short name. Detects existing imports and short-name conflicts.
1516
- **Broader type narrowing.** `instanceof`, type-guard functions, `in_array()` strict mode, `assert()`, `@phpstan-assert-if-true`/`-if-false`, and compound `&&`/`||` conditions now narrow types in if/else branches, guard clauses, while-loop bodies, ternary expressions, and `match(true)` arms.
1617
- **Argument type mismatch diagnostics.** Flags function and method calls where an argument's resolved type is incompatible with the declared parameter type.
1718
- **Invalid class-like kind diagnostics.** Flags class-like names used in positions where their kind is guaranteed to fail at runtime: `new` on abstract classes, interfaces, traits, or enums; `extends` on a final class, interface, or trait; `implements` with a non-interface; trait `use` with a non-trait; `instanceof` with a trait; `catch` with a non-Throwable type; and traits in type-hint positions.

src/code_actions/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ mod promote_constructor_param;
9090
mod remove_unused_import;
9191
pub(crate) use remove_unused_import::build_line_deletion_edit;
9292
mod replace_deprecated;
93+
mod replace_fqcn;
9394
mod simplify_null;
9495
mod update_docblock;
9596

@@ -174,6 +175,9 @@ impl Backend {
174175
// ── Import class ────────────────────────────────────────────────
175176
self.collect_import_class_actions(uri, content, params, &mut actions);
176177

178+
// ── Replace FQCN with import ────────────────────────────────────
179+
self.collect_replace_fqcn_actions(uri, content, params, &mut actions);
180+
177181
// ── Import all missing classes (bulk) ───────────────────────────
178182
self.collect_import_all_classes_action(uri, content, params, &mut actions);
179183

src/code_actions/replace_fqcn.rs

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
//! Replace FQCN with import code action.
2+
//!
3+
//! When the cursor is on a fully-qualified class name (leading `\`), offer
4+
//! a code action that inserts a `use` statement and replaces the FQCN at
5+
//! the call site with the short name.
6+
7+
use std::collections::HashMap;
8+
9+
use tower_lsp::lsp_types::*;
10+
11+
use crate::Backend;
12+
use crate::completion::use_edit::{analyze_use_block, build_use_edit, use_import_conflicts};
13+
use crate::symbol_map::SymbolKind;
14+
use crate::util::{offset_to_position, position_to_byte_offset, short_name};
15+
16+
impl Backend {
17+
/// Collect "Replace FQCN with import" code actions when the cursor
18+
/// is on a fully-qualified class name.
19+
pub(crate) fn collect_replace_fqcn_actions(
20+
&self,
21+
uri: &str,
22+
content: &str,
23+
params: &CodeActionParams,
24+
out: &mut Vec<CodeActionOrCommand>,
25+
) {
26+
let file_use_map: HashMap<String, String> = self.file_use_map(uri);
27+
let file_namespace: Option<String> = self.first_file_namespace(uri);
28+
29+
let symbol_map = match self.symbol_maps.read().get(uri) {
30+
Some(sm) => sm.clone(),
31+
None => return,
32+
};
33+
34+
let request_start = position_to_byte_offset(content, params.range.start);
35+
let request_end = position_to_byte_offset(content, params.range.end);
36+
37+
for span in &symbol_map.spans {
38+
if span.start as usize >= request_end || span.end as usize <= request_start {
39+
continue;
40+
}
41+
42+
let (ref_name, is_fqn) = match &span.kind {
43+
SymbolKind::ClassReference { name, is_fqn, .. } => (name.as_str(), *is_fqn),
44+
_ => continue,
45+
};
46+
47+
// This action only applies to FQNs (leading `\` in source).
48+
if !is_fqn {
49+
continue;
50+
}
51+
52+
// The stored name has no leading `\`, so it's already the FQN.
53+
let fqn = ref_name;
54+
let sn = short_name(fqn);
55+
56+
// If the short name is already imported with the same FQN,
57+
// just offer to replace the inline FQN with the short name
58+
// (no new use statement needed).
59+
let already_imported = file_use_map.iter().any(|(alias, existing_fqn)| {
60+
alias.eq_ignore_ascii_case(sn) && existing_fqn.eq_ignore_ascii_case(fqn)
61+
});
62+
63+
// If there's a conflict (different class with same short name
64+
// already imported), skip.
65+
if !already_imported && use_import_conflicts(fqn, &file_use_map) {
66+
continue;
67+
}
68+
69+
let doc_uri: Url = match uri.parse() {
70+
Ok(u) => u,
71+
Err(_) => continue,
72+
};
73+
74+
let mut edits: Vec<TextEdit> = Vec::new();
75+
76+
// Add use statement if not already imported.
77+
if !already_imported {
78+
let use_block = analyze_use_block(content);
79+
if let Some(use_edits) = build_use_edit(fqn, &use_block, &file_namespace) {
80+
edits.extend(use_edits);
81+
}
82+
}
83+
84+
// Replace the FQN at the call site with the short name.
85+
// The span covers the name without the leading `\`, but in
86+
// source the leading `\` is present, so we need to include it.
87+
let replace_start = if span.start > 0 {
88+
// Check if the character before the span is `\`.
89+
let before = &content[..span.start as usize];
90+
if before.ends_with('\\') {
91+
span.start as usize - 1
92+
} else {
93+
span.start as usize
94+
}
95+
} else {
96+
span.start as usize
97+
};
98+
let replace_end = span.end as usize;
99+
100+
let start_pos = offset_to_position(content, replace_start);
101+
let end_pos = offset_to_position(content, replace_end);
102+
103+
edits.push(TextEdit {
104+
range: Range {
105+
start: start_pos,
106+
end: end_pos,
107+
},
108+
new_text: sn.to_string(),
109+
});
110+
111+
let title = if already_imported {
112+
format!("Replace `\\{}` with short name `{}`", fqn, sn)
113+
} else {
114+
format!("Replace FQCN `\\{}` with import", fqn)
115+
};
116+
117+
let mut changes = HashMap::new();
118+
changes.insert(doc_uri, edits);
119+
120+
out.push(CodeActionOrCommand::CodeAction(CodeAction {
121+
title,
122+
kind: Some(CodeActionKind::REFACTOR),
123+
diagnostics: None,
124+
edit: Some(WorkspaceEdit {
125+
changes: Some(changes),
126+
document_changes: None,
127+
change_annotations: None,
128+
}),
129+
command: None,
130+
is_preferred: Some(false),
131+
disabled: None,
132+
data: None,
133+
}));
134+
135+
break;
136+
}
137+
}
138+
}
139+
140+
#[cfg(test)]
141+
mod tests {
142+
use tower_lsp::lsp_types::*;
143+
144+
fn code_action_titles(content: &str, cursor_offset: usize) -> Vec<String> {
145+
let backend = crate::Backend::new_test();
146+
let uri = "file:///test.php";
147+
backend.update_ast(uri, content);
148+
149+
let pos = crate::util::offset_to_position(content, cursor_offset);
150+
let params = CodeActionParams {
151+
text_document: TextDocumentIdentifier {
152+
uri: uri.parse().unwrap(),
153+
},
154+
range: Range {
155+
start: pos,
156+
end: pos,
157+
},
158+
context: CodeActionContext {
159+
diagnostics: vec![],
160+
only: None,
161+
trigger_kind: None,
162+
},
163+
work_done_progress_params: Default::default(),
164+
partial_result_params: Default::default(),
165+
};
166+
let mut actions = Vec::new();
167+
backend.collect_replace_fqcn_actions(uri, content, &params, &mut actions);
168+
actions
169+
.into_iter()
170+
.map(|a| match a {
171+
CodeActionOrCommand::CodeAction(ca) => ca.title,
172+
_ => String::new(),
173+
})
174+
.collect()
175+
}
176+
177+
#[test]
178+
fn offers_action_on_fqcn() {
179+
let src = "<?php\nnamespace App;\n\n\\Illuminate\\Support\\Str::plural('test');\n";
180+
// Cursor somewhere on the FQN (after the `\`)
181+
let offset = src.find("Illuminate\\Support\\Str").unwrap();
182+
let titles = code_action_titles(src, offset);
183+
assert_eq!(titles.len(), 1);
184+
assert!(titles[0].contains("Replace FQCN"));
185+
assert!(titles[0].contains("Illuminate\\Support\\Str"));
186+
}
187+
188+
#[test]
189+
fn no_action_on_short_name() {
190+
let src =
191+
"<?php\nnamespace App;\n\nuse Illuminate\\Support\\Str;\n\nStr::plural('test');\n";
192+
let offset = src.find("Str::").unwrap();
193+
let titles = code_action_titles(src, offset);
194+
assert!(titles.is_empty());
195+
}
196+
197+
#[test]
198+
fn reuses_existing_import() {
199+
let src = "<?php\nnamespace App;\n\nuse Illuminate\\Support\\Str;\n\n\\Illuminate\\Support\\Str::plural('test');\n";
200+
let offset = src.find("\\Illuminate\\Support\\Str::").unwrap() + 1;
201+
let titles = code_action_titles(src, offset);
202+
assert_eq!(titles.len(), 1);
203+
assert!(titles[0].contains("short name"));
204+
}
205+
206+
#[test]
207+
fn skips_conflicting_import() {
208+
let src = "<?php\nnamespace App;\n\nuse Other\\Str;\n\n\\Illuminate\\Support\\Str::plural('test');\n";
209+
let offset = src.find("\\Illuminate\\Support\\Str::").unwrap() + 1;
210+
let titles = code_action_titles(src, offset);
211+
assert!(titles.is_empty());
212+
}
213+
}

tests/integration/diagnostics_unknown_members.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4946,10 +4946,9 @@ class MyController {
49464946
);
49474947

49484948
let uri = "file:///src/Controller.php";
4949-
let content = std::fs::read_to_string(
4950-
std::path::Path::new(_dir.path()).join("src/Controller.php"),
4951-
)
4952-
.unwrap();
4949+
let content =
4950+
std::fs::read_to_string(std::path::Path::new(_dir.path()).join("src/Controller.php"))
4951+
.unwrap();
49534952
let diags = unknown_member_diagnostics_with_scope_cache(&backend, uri, &content);
49544953
let with_diags: Vec<_> = diags
49554954
.iter()

0 commit comments

Comments
 (0)