1use lsp_types::{
2 DocumentChangeOperation, DocumentChanges, OneOf, OptionalVersionedTextDocumentIdentifier,
3 RenameFile, TextDocumentEdit,
4};
5use rustc_hash::FxHashSet;
6use tinymist_std::path::{PathClean, unix_slash};
7use typst::{
8 foundations::{Repr, Str},
9 syntax::Span,
10};
11
12use crate::adt::interner::Interned;
13use crate::{
14 analysis::{LinkObject, LinkTarget, get_link_exprs},
15 find_references,
16 prelude::*,
17 prepare_renaming,
18 syntax::{Decl, RefExpr, SyntaxClass, first_ancestor_expr, get_index_info, node_ancestors},
19};
20
21#[derive(Debug, Clone)]
27pub struct RenameRequest {
28 pub path: PathBuf,
30 pub position: LspPosition,
32 pub new_name: String,
34}
35
36impl StatefulRequest for RenameRequest {
37 type Response = WorkspaceEdit;
38
39 fn request(self, ctx: &mut LocalContext, graph: LspComputeGraph) -> Option<Self::Response> {
40 let doc = graph.snap.success_doc.as_ref();
41
42 let source = ctx.source_by_path(&self.path).ok()?;
43 let syntax = ctx.classify_for_decl(&source, self.position)?;
44
45 let def = ctx.def_of_syntax(&source, doc, syntax.clone())?;
46
47 prepare_renaming(&syntax, &def)?;
48
49 match syntax {
50 SyntaxClass::ImportPath(path) | SyntaxClass::IncludePath(path) => {
52 let ref_path_str = path.cast::<ast::Str>()?.get();
53 let new_path_str = if !self.new_name.ends_with(".typ") {
54 self.new_name + ".typ"
55 } else {
56 self.new_name
57 };
58
59 let def_fid = def.file_id()?;
60 let old_path = ctx.path_for_id(def_fid).ok()?.to_err().ok()?;
62
63 let new_path = Path::new(new_path_str.as_str());
64 let rename_loc = Path::new(ref_path_str.as_str());
65 let diff = tinymist_std::path::diff(new_path, rename_loc)?;
66 if diff.is_absolute() {
67 log::info!(
68 "bad rename: absolute path, base: {rename_loc:?}, new: {new_path:?}, diff: {diff:?}"
69 );
70 return None;
71 }
72
73 let new_path = old_path.join(&diff).clean();
74
75 let old_uri = path_to_url(&old_path).ok()?;
76 let new_uri = path_to_url(&new_path).ok()?;
77
78 let mut edits: HashMap<Url, Vec<TextEdit>> = HashMap::new();
79 do_rename_file(ctx, def_fid, diff, &mut edits);
80
81 let mut document_changes = edits_to_document_changes(edits);
82
83 document_changes.push(lsp_types::DocumentChangeOperation::Op(
84 lsp_types::ResourceOp::Rename(RenameFile {
85 old_uri,
86 new_uri,
87 options: None,
88 annotation_id: None,
89 }),
90 ));
91
92 Some(WorkspaceEdit {
94 document_changes: Some(DocumentChanges::Operations(document_changes)),
95 ..Default::default()
96 })
97 }
98 _ => {
99 let references = find_references(ctx, &source, doc, syntax)?;
100
101 let mut edits = HashMap::new();
102
103 for loc in references {
104 let uri = loc.uri;
105 let range = loc.range;
106 let edits = edits.entry(uri).or_insert_with(Vec::new);
107 edits.push(TextEdit {
108 range,
109 new_text: self.new_name.clone(),
110 });
111 }
112
113 log::info!("rename edits: {edits:?}");
114
115 Some(WorkspaceEdit {
116 changes: Some(edits),
117 ..Default::default()
118 })
119 }
120 }
121 }
122}
123
124pub(crate) fn do_rename_file(
125 ctx: &mut LocalContext,
126 def_fid: TypstFileId,
127 diff: PathBuf,
128 edits: &mut HashMap<Url, Vec<TextEdit>>,
129) -> Option<()> {
130 let def_path = def_fid
131 .vpath()
132 .as_rooted_path()
133 .file_name()
134 .unwrap_or_default()
135 .to_str()
136 .unwrap_or_default()
137 .into();
138 let mut ctx = RenameFileWorker {
139 ctx,
140 def_fid,
141 def_path,
142 diff,
143 inserted: FxHashSet::default(),
144 };
145 ctx.work(edits)
146}
147
148struct RenameFileWorker<'a> {
149 ctx: &'a mut LocalContext,
150 def_fid: TypstFileId,
151 def_path: Interned<str>,
152 diff: PathBuf,
153 inserted: FxHashSet<Span>,
154}
155
156impl RenameFileWorker<'_> {
157 pub(crate) fn work(&mut self, edits: &mut HashMap<Url, Vec<TextEdit>>) -> Option<()> {
158 let dep = self.ctx.module_dependencies().get(&self.def_fid).cloned();
159 if let Some(dep) = dep {
160 for ref_fid in dep.dependents.iter() {
161 self.refs_in_file(*ref_fid, edits);
162 }
163 }
164
165 for ref_fid in self.ctx.source_files().clone() {
166 self.links_in_file(ref_fid, edits);
167 }
168
169 Some(())
170 }
171
172 fn refs_in_file(
173 &mut self,
174 ref_fid: TypstFileId,
175 edits: &mut HashMap<Url, Vec<TextEdit>>,
176 ) -> Option<()> {
177 let ref_src = self.ctx.source_by_id(ref_fid).ok()?;
178 let uri = self.ctx.uri_for_id(ref_fid).ok()?;
179
180 let import_info = self.ctx.expr_stage(&ref_src);
181
182 let edits = edits.entry(uri).or_default();
183 for (span, r) in &import_info.resolves {
184 if !matches!(
185 r.decl.as_ref(),
186 Decl::ImportPath(..) | Decl::IncludePath(..) | Decl::PathStem(..)
187 ) {
188 continue;
189 }
190
191 if let Some(edit) = self.rename_module_path(*span, r, &ref_src) {
192 edits.push(edit);
193 }
194 }
195
196 Some(())
197 }
198
199 fn links_in_file(
200 &mut self,
201 ref_fid: TypstFileId,
202 edits: &mut HashMap<Url, Vec<TextEdit>>,
203 ) -> Option<()> {
204 let ref_src = self.ctx.source_by_id(ref_fid).ok()?;
205
206 let index = get_index_info(&ref_src);
207 if !index.paths.contains(&self.def_path) {
208 return Some(());
209 }
210
211 let uri = self.ctx.uri_for_id(ref_fid).ok()?;
212
213 let link_info = get_link_exprs(&ref_src);
214 let root = LinkedNode::new(ref_src.root());
215 let edits = edits.entry(uri).or_default();
216 for obj in &link_info.objects {
217 if !matches!(&obj.target,
218 LinkTarget::Path(file_id, _) if *file_id == self.def_fid
219 ) {
220 continue;
221 }
222 if let Some(edit) = self.rename_resource_path(obj, &root, &ref_src) {
223 edits.push(edit);
224 }
225 }
226
227 Some(())
228 }
229
230 fn rename_resource_path(
231 &mut self,
232 obj: &LinkObject,
233 root: &LinkedNode,
234 src: &Source,
235 ) -> Option<TextEdit> {
236 let r = root.find(obj.span)?;
237 self.rename_path_expr(r.clone(), r.cast()?, src, false)
238 }
239
240 fn rename_module_path(&mut self, span: Span, r: &RefExpr, src: &Source) -> Option<TextEdit> {
241 let importing = r.root.as_ref()?.file_id();
242
243 if importing != Some(self.def_fid) {
244 return None;
245 }
246 crate::log_debug_ct!("import: {span:?} -> {importing:?} v.s. {:?}", self.def_fid);
247 let root = LinkedNode::new(src.root());
250 let import_node = root.find(span).and_then(first_ancestor_expr)?;
251 let (import_path, has_path_var) = node_ancestors(&import_node).find_map(|import_node| {
252 match import_node.cast::<ast::Expr>()? {
253 ast::Expr::Import(import) => Some((
254 import.source(),
255 import.new_name().is_none() && import.imports().is_none(),
256 )),
257 ast::Expr::Include(include) => Some((include.source(), false)),
258 _ => None,
259 }
260 })?;
261
262 self.rename_path_expr(import_node.clone(), import_path, src, has_path_var)
263 }
264
265 fn rename_path_expr(
266 &mut self,
267 node: LinkedNode,
268 path: ast::Expr,
269 src: &Source,
270 has_path_var: bool,
271 ) -> Option<TextEdit> {
272 let new_text = match path {
273 ast::Expr::Str(s) => {
274 if !self.inserted.insert(s.span()) {
275 return None;
276 }
277
278 let old_str = s.get();
279 let old_path = Path::new(old_str.as_str());
280 let new_path = old_path.join(&self.diff).clean();
281 let new_str = unix_slash(&new_path);
282
283 let path_part = Str::from(new_str).repr();
284 let need_alias = new_path.file_name() != old_path.file_name();
285
286 if has_path_var && need_alias {
287 let alias = old_path.file_stem()?.to_str()?;
288 format!("{path_part} as {alias}")
289 } else {
290 path_part.to_string()
291 }
292 }
293 _ => return None,
294 };
295
296 let import_path_range = node.find(path.span())?.range();
297 let range = self.ctx.to_lsp_range(import_path_range, src);
298
299 Some(TextEdit { range, new_text })
300 }
301}
302
303pub(crate) fn edits_to_document_changes(
304 edits: HashMap<Url, Vec<TextEdit>>,
305) -> Vec<DocumentChangeOperation> {
306 let mut document_changes = vec![];
307
308 for (uri, edits) in edits {
309 document_changes.push(lsp_types::DocumentChangeOperation::Edit(TextDocumentEdit {
310 text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None },
311 edits: edits.into_iter().map(OneOf::Left).collect(),
312 }));
313 }
314
315 document_changes
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::tests::*;
322
323 #[test]
324 fn test() {
325 snapshot_testing("rename", &|ctx, path| {
326 let source = ctx.source_by_path(&path).unwrap();
327
328 let request = RenameRequest {
329 path: path.clone(),
330 position: find_test_position(&source),
331 new_name: "new_name".to_string(),
332 };
333 let snap = WorldComputeGraph::from_world(ctx.world.clone());
334
335 let mut result = request.request(ctx, snap);
336 if let Some(r) = result.as_mut().and_then(|r| r.changes.as_mut()) {
338 for edits in r.values_mut() {
339 edits.sort_by(|a, b| {
340 a.range
341 .start
342 .cmp(&b.range.start)
343 .then(a.range.end.cmp(&b.range.end))
344 });
345 }
346 };
347
348 assert_snapshot!(JsonRepr::new_redacted(result, &REDACT_LOC));
349 });
350 }
351}