feat: add path conversion actions for absolute and relative paths (#1696)

* feat: add path conversion actions for absolute and relative paths in special function call

* feat: implement matchers

* docs: edit comment

* fix: path on windows

* feat: add a comment

* dev: edit a bit

* dev: use `resolved`

* refactor: simplify path rewriting logic using `diff`

* feat: add absolute path import fixture

* fix: update path check for absolute paths to use `starts_with` to work with windows

* feat: add path expression import fixture

---------

Co-authored-by: Myriad-Dreamin <camiyoru@gmail.com>
This commit is contained in:
Luyan Zhou 2025-04-30 17:15:42 +08:00 committed by GitHub
parent 11cfb08be5
commit 6118b346d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 287 additions and 1 deletions

View file

@ -1,7 +1,11 @@
//! Provides code actions for the document.
use regex::Regex;
use tinymist_analysis::syntax::{adjust_expr, node_ancestors, SyntaxClass};
use tinymist_std::path::{diff, unix_slash};
use super::get_link_exprs_in;
use crate::analysis::LinkTarget;
use crate::prelude::*;
use crate::syntax::{interpret_mode_at, InterpretMode};
@ -55,6 +59,7 @@ impl<'a> CodeActionWorker<'a> {
let mut heading_resolved = false;
let mut equation_resolved = false;
let mut path_resolved = false;
self.wrap_actions(node, range);
@ -70,6 +75,10 @@ impl<'a> CodeActionWorker<'a> {
equation_resolved = true;
self.equation_actions(node);
}
SyntaxKind::Str if !path_resolved => {
path_resolved = true;
self.path_actions(node, cursor);
}
_ => {}
}
@ -77,6 +86,100 @@ impl<'a> CodeActionWorker<'a> {
}
}
fn path_actions(&mut self, node: &LinkedNode, cursor: usize) -> Option<()> {
// We can only process the case where the import path is a string.
if let Some(SyntaxClass::IncludePath(path_node) | SyntaxClass::ImportPath(path_node)) =
classify_syntax(node.clone(), cursor)
{
let str_node = adjust_expr(path_node)?;
let str_ast = str_node.cast::<ast::Str>()?;
return self.path_rewrite(self.source.id(), &str_ast.get(), &str_node);
}
let link_parent = node_ancestors(node)
.find(|node| matches!(node.kind(), SyntaxKind::FuncCall))
.unwrap_or(node);
// Actually there should be only one link left
if let Some(link_info) = get_link_exprs_in(link_parent) {
let objects = link_info.objects.into_iter();
let object_under_node = objects.filter(|link| link.range.contains(&cursor));
let mut resolved = false;
for link in object_under_node {
if let LinkTarget::Path(id, path) = link.target {
// todo: is there a link that is not a path string?
resolved = self.path_rewrite(id, &path, node).is_some() || resolved;
}
}
return resolved.then_some(());
}
None
}
/// Rewrites absolute paths from/to relative paths.
fn path_rewrite(&mut self, id: TypstFileId, path: &str, node: &LinkedNode) -> Option<()> {
if !matches!(node.kind(), SyntaxKind::Str) {
log::warn!("bad path node kind on code action: {:?}", node.kind());
return None;
}
let path = Path::new(path);
if path.starts_with("/") {
// Convert absolute path to relative path
let cur_path = id.vpath().as_rooted_path().parent().unwrap();
let new_path = diff(path, cur_path)?;
let edit = self.edit_str(node, unix_slash(&new_path))?;
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "Convert to relative path".to_string(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(edit),
..CodeAction::default()
});
self.actions.push(action);
} else {
// Convert relative path to absolute path
let mut new_path = id.vpath().as_rooted_path().parent().unwrap().to_path_buf();
for i in path.components() {
match i {
std::path::Component::ParentDir => {
new_path.pop().then_some(())?;
}
std::path::Component::Normal(name) => {
new_path.push(name);
}
_ => {}
}
}
let edit = self.edit_str(node, unix_slash(&new_path))?;
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "Convert to absolute path".to_string(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(edit),
..CodeAction::default()
});
self.actions.push(action);
}
Some(())
}
fn edit_str(&mut self, node: &LinkedNode, new_content: String) -> Option<WorkspaceEdit> {
if !matches!(node.kind(), SyntaxKind::Str) {
log::warn!("edit_str only works on string AST nodes: {:?}", node.kind());
return None;
}
self.local_edit(TextEdit {
range: self.ctx.to_lsp_range(node.range(), &self.source),
// todo: this is merely ocasionally correct, abusing string escape (`fmt::Debug`)
new_text: format!("{new_content:?}"),
})
}
fn wrap_actions(&mut self, node: &LinkedNode, range: Range<usize>) -> Option<()> {
if range.is_empty() {
return None;

View file

@ -80,3 +80,29 @@ impl SemanticRequest for CodeActionRequest {
(!worker.actions.is_empty()).then_some(worker.actions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::*;
#[test]
fn test() {
snapshot_testing("code_action", &|ctx, path| {
let source = ctx.source_by_path(&path).unwrap();
let request = CodeActionRequest {
path: path.clone(),
range: find_test_range(&source),
};
let result = request.request(ctx);
insta::with_settings!({
description => format!("Code Action on {})", make_range_annoation(&source)),
}, {
assert_snapshot!(JsonRepr::new_redacted(result, &REDACT_LOC));
})
});
}
}

View file

@ -0,0 +1,3 @@
/// path: base.typ
-----
#import "/base.typ"/* range -3..-3 */;

View file

@ -0,0 +1,3 @@
/// path: base.typ
-----
$#import "base.typ"/* range -3..-3 */;$

View file

@ -0,0 +1,3 @@
/// path: base.typ
-----
#import "/" + "base.typ"/* range -3..-3 */;

View file

@ -0,0 +1,3 @@
/// path: base.typ
-----
#import "base.typ"/* range -3..-3 */;

View file

@ -0,0 +1,3 @@
/// path: base.typ
-----
#import ("base.typ")/* range -3..-3 */;

View file

@ -0,0 +1,4 @@
/// path: base.json
{}
-----
#json("base.json" /* range -4..-4 */);

View file

@ -0,0 +1,22 @@
---
source: crates/tinymist-query/src/code_action.rs
description: "Code Action on t \"/base.t||yp\"/* rang)"
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/absolute_path_import.typ
---
[
{
"edit": {
"changes": {
"s1.typ": [
{
"newText": "\"base.typ\"",
"range": "0:8:0:19"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to relative path"
}
]

View file

@ -0,0 +1,58 @@
---
source: crates/tinymist-query/src/code_action.rs
description: "Code Action on rt \"base.t||yp\"/* rang)"
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/path_and_equation.typ
---
[
{
"edit": {
"changes": {
"s1.typ": [
{
"newText": "\"/base.typ\"",
"range": "0:9:0:19"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to absolute path"
},
{
"edit": {
"changes": {
"s1.typ": [
{
"newText": " ",
"range": "0:1:0:1"
},
{
"newText": " ",
"range": "0:38:0:38"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to block equation"
},
{
"edit": {
"changes": {
"s1.typ": [
{
"newText": "\n",
"range": "0:1:0:1"
},
{
"newText": "\n",
"range": "0:38:0:38"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to multiple-line block equation"
}
]

View file

@ -0,0 +1,7 @@
---
source: crates/tinymist-query/src/code_action.rs
description: "Code Action on + \"base.t||yp\"/* rang)"
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/path_expression_import.typ
---
null

View file

@ -0,0 +1,22 @@
---
source: crates/tinymist-query/src/code_action.rs
description: "Code Action on rt \"base.t||yp\"/* rang)"
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/path_import.typ
---
[
{
"edit": {
"changes": {
"s1.typ": [
{
"newText": "\"/base.typ\"",
"range": "0:8:0:18"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to absolute path"
}
]

View file

@ -0,0 +1,7 @@
---
source: crates/tinymist-query/src/code_action.rs
description: "Code Action on (\"base.ty||p\")/* rang)"
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/path_import_paren.typ
---
null

View file

@ -0,0 +1,22 @@
---
source: crates/tinymist-query/src/code_action.rs
description: "Code Action on n(\"base.js||on\" /* ran)"
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_action/path_json.typ
---
[
{
"edit": {
"changes": {
"s1.typ": [
{
"newText": "\"/base.json\"",
"range": "0:6:0:17"
}
]
}
},
"kind": "refactor.rewrite",
"title": "Convert to absolute path"
}
]