tinymist_query/
rename.rs

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/// The [`textDocument/rename`] request is sent from the client to the server to
22/// ask the server to compute a workspace change so that the client can perform
23/// a workspace-wide rename of a symbol.
24///
25/// [`textDocument/rename`]: https://microsoft.github.io/language-server-protocol/specification#textDocument_rename
26#[derive(Debug, Clone)]
27pub struct RenameRequest {
28    /// The path of the document to request for.
29    pub path: PathBuf,
30    /// The source code position to request for.
31    pub position: LspPosition,
32    /// The new name to rename to.
33    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            // todo: abs path
51            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                // todo: rename in untitled files
61                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                // todo: validate: workspace.workspaceEdit.resourceOperations
93                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        // rename_importer(self.ctx, &ref_src, *span, &self.diff, edits);
248
249        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            // sort the edits to make the snapshot stable
337            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}