mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
feat(plugins): let a code action create, rename and delete files (#1778)
A new optional file-actions export (worlds plugin-file-actions / file-actions-provider, API 1.3) lists quick fixes whose operations (create with text, rename, delete) and text edits are applied as one workspace edit, so a failure changes nothing and one undo reverses it; paths stay inside the project root, and `open` shows a file once the action is applied. Like providers' code actions, an action can carry only `data` and get its operations from `resolve` when picked. Co-authored-by: arnaud-ritti <[email protected]>
This commit is contained in:
1 parent
1ab4edc0d7
commit
25d930b780
58 files changed
+896
-52
No files matched your search
+186
-21
@@ -7,6 +7,7 @@ use ide_core::error::ErrorKind;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
use ide_plugin::ProviderDocument;
|
||||
use std::path::Path;
|
||||
|
||||
async fn prepare_actions(
|
||||
project: std::sync::Arc<crate::Project>,
|
||||
@@ -85,7 +86,12 @@ const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PluginActionRef {
|
||||
plugin: String,
|
||||
data: String,
|
||||
data: Option<String>,
|
||||
/// Opened once the action is applied.
|
||||
open: Option<ide_core::IpcPath>,
|
||||
/// Listed by the plugin's `file-actions` export, which resolves it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
file: bool,
|
||||
}
|
||||
|
||||
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
|
||||
@@ -443,7 +449,7 @@ pub async fn lsp_code_actions(
|
||||
};
|
||||
let plugin_actions: Vec<CodeAction> = plugin_actions
|
||||
.into_iter()
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, a))
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, &project.root, a))
|
||||
.filter(|a| {
|
||||
only.is_empty()
|
||||
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
|
||||
@@ -459,23 +465,26 @@ pub async fn lsp_code_actions(
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
// With `data` and no edits, the plugin computes them when the action is picked.
|
||||
let lazy = a.edits.is_empty() && a.data.is_some();
|
||||
let files: Vec<_> = a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
|
||||
.collect();
|
||||
let data = a
|
||||
.data
|
||||
.filter(|_| lazy)
|
||||
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
|
||||
fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
// With `data` and nothing to apply, the plugin computes the edits when the action is picked.
|
||||
let lazy = a.edits.is_empty() && a.operations.is_empty() && a.data.is_some();
|
||||
let (files, open, disabled_reason) = match plugin_file_edits(root, &a) {
|
||||
Ok((files, open)) => (files, open, None),
|
||||
Err(reason) => (Vec::new(), None, Some(reason)),
|
||||
};
|
||||
let data = (lazy || open.is_some())
|
||||
.then(|| PluginActionRef {
|
||||
plugin: plugin.to_owned(),
|
||||
data: a.data.filter(|_| lazy),
|
||||
open: open.map(Into::into),
|
||||
file: a.file_action,
|
||||
})
|
||||
.and_then(|r| serde_json::to_value(r).ok());
|
||||
CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
is_preferred: a.preferred,
|
||||
disabled_reason: None,
|
||||
disabled_reason,
|
||||
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
command: None,
|
||||
data,
|
||||
@@ -483,6 +492,53 @@ fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// The action's file operations, then its text edits, as one workspace edit's files, and the
|
||||
/// file to open. An action with file operations or an `open` target stays inside the project
|
||||
/// root as a whole: a path outside it disables the action.
|
||||
fn plugin_file_edits(
|
||||
root: &Path,
|
||||
a: &ide_plugin::CodeAction,
|
||||
) -> Result<(Vec<ide_core::FileEdit>, Option<std::path::PathBuf>), String> {
|
||||
use ide_core::FileEdit;
|
||||
use ide_plugin::FileOperation;
|
||||
let confine = !a.operations.is_empty() || a.open.is_some();
|
||||
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
|
||||
let mut files = Vec::new();
|
||||
let mut created_with_text = Vec::new();
|
||||
for operation in &a.operations {
|
||||
match operation {
|
||||
FileOperation::Create { path, text, overwrite } => {
|
||||
let path = confined(path)?;
|
||||
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
|
||||
if !text.is_empty() {
|
||||
let start = Range::point(Pos::new(0, 0));
|
||||
files.push(FileEdit::Edit {
|
||||
path: path.clone().into(),
|
||||
edits: vec![TextEdit { range: start, new_text: text.clone() }],
|
||||
version: None,
|
||||
});
|
||||
created_with_text.push(path);
|
||||
}
|
||||
}
|
||||
FileOperation::Rename { from, to } => {
|
||||
files.push(FileEdit::Rename { from: confined(from)?.into(), to: confined(to)?.into() });
|
||||
}
|
||||
FileOperation::Delete { path } => {
|
||||
files.push(FileEdit::Delete { path: confined(path)?.into(), recursive: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (path, edits) in &a.edits {
|
||||
let path = if confine { confined(path)? } else { path.clone() };
|
||||
if created_with_text.contains(&path) {
|
||||
return Err("put the text of a created file in its `create`, not in an edit".into());
|
||||
}
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: edits.clone(), version: None });
|
||||
}
|
||||
let open = a.open.as_deref().map(confined).transpose()?;
|
||||
Ok((files, open))
|
||||
}
|
||||
|
||||
/// A code action resolved by whoever offered it: its language server, or
|
||||
/// the plugin its `data` names.
|
||||
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
|
||||
@@ -499,11 +555,15 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
|
||||
kind: action.kind.clone(),
|
||||
preferred: action.is_preferred,
|
||||
edits: Vec::new(),
|
||||
data: Some(r.data),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: r.data,
|
||||
file_action: r.file,
|
||||
};
|
||||
let plugin = r.plugin.clone();
|
||||
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
|
||||
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
let root = host.project(window)?.root.clone();
|
||||
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
@@ -554,11 +614,15 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
} else {
|
||||
action
|
||||
};
|
||||
if let Some(reason) = &action.disabled_reason {
|
||||
return Err(ide_core::IdeError::invalid(reason.clone()));
|
||||
}
|
||||
if let Some(edit) = &action.edit {
|
||||
let documents = project.documents.clone();
|
||||
let edit = edit.clone();
|
||||
blocking(move || apply_edit(&documents, &edit)).await?;
|
||||
}
|
||||
open_plugin_action_target(&action, window);
|
||||
if let Some(command) = action.command
|
||||
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
|
||||
{
|
||||
@@ -567,6 +631,24 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The plugin and the file its action asks to show once applied.
|
||||
fn plugin_action_open(action: &CodeAction) -> Option<(String, ide_core::IpcPath)> {
|
||||
if action.server != PLUGIN_SERVER {
|
||||
return None;
|
||||
}
|
||||
let r = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok())?;
|
||||
Some((r.plugin, r.open?))
|
||||
}
|
||||
|
||||
/// Opens the file a plugin's action asked to show, now that the action is applied.
|
||||
fn open_plugin_action_target(action: &CodeAction, window: &str) {
|
||||
let (Some((plugin, path)), Some(plugins)) = (plugin_action_open(action), crate::plugins::editor_plugin_host())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
plugins.open_file(&plugin, window, path);
|
||||
}
|
||||
|
||||
/// A command after a code action's edit that no server provides: what other editors'
|
||||
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
|
||||
/// is the action; there is nothing to run.
|
||||
@@ -846,6 +928,8 @@ pub async fn lsp_execute_code_action(
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let edited = action.edit.is_some();
|
||||
// A plugin's action may name a file to show once applied; the caller opens it.
|
||||
let open = plugin_action_open(&action).map(|(_, path)| path);
|
||||
if let Some(edit) = action.edit {
|
||||
let documents = project.documents.clone();
|
||||
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
|
||||
@@ -861,7 +945,7 @@ pub async fn lsp_execute_code_action(
|
||||
.execute_action_command(doc, action.server, command, answers, cancel)
|
||||
.await
|
||||
.map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()),
|
||||
None => Ok(Vec::new()),
|
||||
None => Ok(open.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
@@ -1025,15 +1109,19 @@ mod tests {
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits,
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: data.map(str::to_owned),
|
||||
file_action: false,
|
||||
};
|
||||
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
|
||||
let root = Path::new("/p");
|
||||
let lazy = plugin_code_action("acme.x", root, action(Vec::new(), Some("ref")));
|
||||
assert!(lazy.edit.is_none());
|
||||
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_deref()), ("acme.x", Some("ref")));
|
||||
// With edits, `data` is the plugin's business: applied as is.
|
||||
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
|
||||
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
let eager = plugin_code_action("acme.x", root, action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
assert!(eager.edit.is_some() && eager.data.is_none());
|
||||
}
|
||||
|
||||
@@ -1110,6 +1198,83 @@ mod tests {
|
||||
assert!(without_generated_helpers(Vec::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
|
||||
use ide_core::FileEdit;
|
||||
use ide_plugin::FileOperation;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
|
||||
title: "Create view".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
edits: vec![(root.join("a.txt"), vec![TextEdit { range: Range::default(), new_text: "x".into() }])],
|
||||
operations,
|
||||
open: Some(root.join("views/home.html")),
|
||||
data: None,
|
||||
file_action: true,
|
||||
};
|
||||
|
||||
let created = plugin_code_action(
|
||||
"acme.x",
|
||||
&root,
|
||||
action(vec![
|
||||
FileOperation::Create { path: "views/home.html".into(), text: "<h1/>".into(), overwrite: false },
|
||||
FileOperation::Rename { from: root.join("old.txt"), to: root.join("new.txt") },
|
||||
FileOperation::Delete { path: root.join("gone.txt") },
|
||||
]),
|
||||
);
|
||||
assert_eq!(created.disabled_reason, None);
|
||||
let files = created.edit.unwrap().files;
|
||||
let home = root.join("views/home.html");
|
||||
assert!(matches!(&files[0], FileEdit::Create { path, overwrite: false } if **path == home));
|
||||
assert!(
|
||||
matches!(&files[1], FileEdit::Edit { path, edits, .. } if **path == home && edits[0].new_text == "<h1/>")
|
||||
);
|
||||
assert!(matches!(&files[2], FileEdit::Rename { to, .. } if **to == root.join("new.txt")));
|
||||
assert!(matches!(&files[3], FileEdit::Delete { recursive: false, .. }));
|
||||
assert!(matches!(&files[4], FileEdit::Edit { path, .. } if **path == root.join("a.txt")));
|
||||
let r: PluginActionRef = serde_json::from_value(created.data.unwrap()).unwrap();
|
||||
assert_eq!(r.data, None);
|
||||
assert!(r.file, "resolved by the file-actions export");
|
||||
let opened = r.open.unwrap().to_path_buf();
|
||||
assert!(opened.components().eq(dunce::simplified(&home).components()), "{opened:?}");
|
||||
|
||||
let outside = tmp.path().join("evil.txt");
|
||||
let mut open_outside = action(Vec::new());
|
||||
open_outside.open = Some(outside.clone());
|
||||
assert!(plugin_code_action("acme.x", &root, open_outside).disabled_reason.is_some());
|
||||
let mut edit_outside = action(vec![FileOperation::Delete { path: root.join("gone.txt") }]);
|
||||
edit_outside.edits.push((outside, Vec::new()));
|
||||
assert!(plugin_code_action("acme.x", &root, edit_outside).disabled_reason.is_some());
|
||||
let mut edit_created =
|
||||
action(vec![FileOperation::Create { path: root.join("n.txt"), text: "x".into(), overwrite: false }]);
|
||||
edit_created.edits = vec![(root.join("n.txt"), Vec::new())];
|
||||
assert!(plugin_code_action("acme.x", &root, edit_created).disabled_reason.is_some());
|
||||
|
||||
for outside in [tmp.path().join("evil.txt"), "../evil.txt".into()] {
|
||||
for operation in [
|
||||
FileOperation::Create { path: outside.clone(), text: String::new(), overwrite: false },
|
||||
FileOperation::Rename { from: root.join("old.txt"), to: outside.clone() },
|
||||
FileOperation::Delete { path: outside.clone() },
|
||||
] {
|
||||
let rejected = plugin_code_action("acme.x", &root, action(vec![operation]));
|
||||
assert!(rejected.disabled_reason.is_some_and(|r| r.contains("outside the project root")));
|
||||
assert!(rejected.edit.unwrap().files.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// `data` and nothing to apply: the file-actions export computes it when picked.
|
||||
let mut lazy = action(Vec::new());
|
||||
(lazy.edits, lazy.open, lazy.data) = (Vec::new(), None, Some("ref".into()));
|
||||
let lazy = plugin_code_action("acme.x", &root, lazy);
|
||||
assert!(lazy.edit.is_none());
|
||||
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
|
||||
assert_eq!((r.data.as_deref(), r.file), (Some("ref"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_completion_kind_the_plugin_api_documents_is_understood() {
|
||||
let wit = include_str!("../../../../wit/providers.wit");
|
||||
|
||||
@@ -169,7 +169,7 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
|
||||
|
||||
// ---- dispatch ---------------------------------------------------------------
|
||||
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus};
|
||||
use super::{LoadedPlugin, PluginEvent, PluginHost, PluginStatus};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::{
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
|
||||
@@ -323,6 +323,12 @@ impl PluginHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Shows `path` in window `client` after a code action of `plugin_id` was applied.
|
||||
pub fn open_file(&self, plugin_id: &str, client: &str, path: ide_core::IpcPath) {
|
||||
let event = PluginEvent::OpenLocation { plugin_id: plugin_id.to_owned(), path, line: 0, column: 0 };
|
||||
self.emit(Some(client), event);
|
||||
}
|
||||
|
||||
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
|
||||
|
||||
@@ -2951,6 +2951,30 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
|
||||
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
|
||||
|
||||
// File operations come from the `file-actions` export, next to the providers' own.
|
||||
let mut actions = f.host.provide_code_actions(&doc("files"), ide_core::Range::default()).into_iter();
|
||||
let (_, action) = actions.next().expect("offered");
|
||||
let (plugin, later) = actions.next().expect("a lazy one too");
|
||||
let dir = f.project.clone();
|
||||
assert_eq!(action.title, "Reorganise");
|
||||
assert_eq!(
|
||||
action.operations,
|
||||
vec![
|
||||
ide_plugin::FileOperation::Create { path: dir.join("created.txt"), text: "hello".into(), overwrite: false },
|
||||
ide_plugin::FileOperation::Rename { from: dir.join("old.txt"), to: dir.join("renamed.txt") },
|
||||
ide_plugin::FileOperation::Delete { path: dir.join("gone.txt") },
|
||||
]
|
||||
);
|
||||
assert_eq!(action.open, Some(dir.join("created.txt")));
|
||||
// Listed with `data` only: `resolve` on the same export computes its operations.
|
||||
assert!(later.file_action && later.operations.is_empty() && later.data.is_some());
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later).unwrap();
|
||||
assert_eq!(
|
||||
resolved.operations,
|
||||
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
|
||||
);
|
||||
assert!(f.host.provide_code_actions(&doc("rich"), ide_core::Range::default()).is_empty());
|
||||
|
||||
// A kind this IDE doesn't know is refused, not a trap.
|
||||
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no event kind"), "{}", err.message);
|
||||
|
||||
@@ -20,6 +20,6 @@ pub use runtime::{
|
||||
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
|
||||
component_api, host_api,
|
||||
};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root};
|
||||
pub use types::*;
|
||||
pub use wasmtime::component::Component;
|
||||
@@ -4,8 +4,8 @@
|
||||
//! themselves are in `wit_host`.
|
||||
|
||||
use crate::types::{
|
||||
CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
|
||||
PluginInlayHint, ProviderDocument, SemanticToken,
|
||||
CodeAction, CommitDraft, CommitProblem, CompletionItem, FileOperation, InlineSuggestion, LineMarker, Location,
|
||||
PluginHover, PluginInlayHint, ProviderDocument, SemanticToken,
|
||||
};
|
||||
use crate::types::{
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
|
||||
@@ -16,8 +16,8 @@ use crate::types::{
|
||||
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{
|
||||
bindings, commit_checks_bindings, details_bindings, hover_bindings, indexing_bindings, inlay_bindings,
|
||||
providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
|
||||
bindings, commit_checks_bindings, details_bindings, file_actions_bindings, hover_bindings, indexing_bindings,
|
||||
inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
|
||||
};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
@@ -37,6 +37,7 @@ use bindings::openidea::plugin::types as wit_types;
|
||||
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
|
||||
use details_bindings::exports::openidea::plugin::detail_provider as wit_detail;
|
||||
use details_bindings::openidea::plugin::detail_types as wit_dt;
|
||||
use file_actions_bindings::exports::openidea::plugin::file_actions as wit_fa;
|
||||
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
|
||||
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
|
||||
use inlay_bindings::exports::openidea::plugin::inlay_hints_provider as wit_inlay;
|
||||
@@ -543,6 +544,7 @@ struct Exports {
|
||||
syntax: Option<wit_syntax::Guest>,
|
||||
providers: Option<wit_prov::Guest>,
|
||||
indexer: Option<wit_index::Guest>,
|
||||
file_actions: Option<wit_fa::Guest>,
|
||||
views: Option<wit_view::Guest>,
|
||||
semantic: Option<wit_sem::Guest>,
|
||||
details: Option<wit_detail::Guest>,
|
||||
@@ -626,6 +628,7 @@ impl Runtime {
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
|
||||
let index_indices = wit_index::GuestIndices::new(&pre).ok();
|
||||
let file_action_indices = wit_fa::GuestIndices::new(&pre).ok();
|
||||
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
|
||||
@@ -647,6 +650,10 @@ impl Runtime {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let file_actions = match file_action_indices {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let views = match view_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
@@ -684,6 +691,7 @@ impl Runtime {
|
||||
hover,
|
||||
commit_checks,
|
||||
inlay,
|
||||
file_actions,
|
||||
},
|
||||
limits: setup.limits,
|
||||
version: setup.version,
|
||||
@@ -866,9 +874,23 @@ impl Runtime {
|
||||
selection: ide_core::Range,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<CodeAction>, CallError> {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
|
||||
let mut out = Vec::new();
|
||||
if self.exports.providers.is_some() {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit));
|
||||
}
|
||||
if self.exports.file_actions.is_some() {
|
||||
let d = file_action_doc(doc);
|
||||
let actions = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.file_actions.as_ref().expect("checked").call_code_actions(store, &d, range_to_wit(selection))
|
||||
})?;
|
||||
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(file_action_from_wit));
|
||||
}
|
||||
if out.is_empty() && self.exports.providers.is_none() {
|
||||
return Err(CallError::missing_export("providers"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The edits of an action `provide_code_actions` returned with `data`.
|
||||
@@ -878,6 +900,17 @@ impl Runtime {
|
||||
action: &CodeAction,
|
||||
timeout: Duration,
|
||||
) -> Result<CodeAction, CallError> {
|
||||
if action.file_action {
|
||||
if self.exports.file_actions.is_none() {
|
||||
return Err(CallError::missing_export("file-actions"));
|
||||
}
|
||||
let d = file_action_doc(doc);
|
||||
let a = file_action_to_wit(action);
|
||||
let resolved = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.file_actions.as_ref().expect("checked").call_resolve(store, &d, &a)
|
||||
})?;
|
||||
return resolved.map(file_action_from_wit).map_err(|e| CallError::Guest(e.into()));
|
||||
}
|
||||
let d = doc_to_wit(doc);
|
||||
let a = code_action_to_wit(action);
|
||||
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
|
||||
@@ -1281,7 +1314,10 @@ fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
file_action: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1305,6 +1341,82 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_FILE_OPERATIONS: usize = 1000;
|
||||
|
||||
fn file_action_doc(doc: &ProviderDocument) -> wit_fa::Document {
|
||||
wit_fa::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_from_wit(a: wit_fa::FileAction) -> CodeAction {
|
||||
CodeAction {
|
||||
title: clip(a.title, MAX_TITLE),
|
||||
kind: a.kind.map(|k| clip(k, 64)),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
operations: a.operations.into_iter().take(MAX_FILE_OPERATIONS).map(operation_from_wit).collect(),
|
||||
open: a.open.map(std::path::PathBuf::from),
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
file_action: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_to_wit(a: &CodeAction) -> wit_fa::FileAction {
|
||||
wit_fa::FileAction {
|
||||
title: a.title.clone(),
|
||||
kind: a.kind.clone(),
|
||||
preferred: a.preferred,
|
||||
operations: a
|
||||
.operations
|
||||
.iter()
|
||||
.map(|op| match op {
|
||||
FileOperation::Create { path, text, overwrite } => wit_fa::FileOperation::Create(wit_fa::CreateFile {
|
||||
path: to_ipc(path),
|
||||
text: text.clone(),
|
||||
overwrite: *overwrite,
|
||||
}),
|
||||
FileOperation::Rename { from, to } => {
|
||||
wit_fa::FileOperation::Rename(wit_fa::RenameFile { path: to_ipc(from), new_path: to_ipc(to) })
|
||||
}
|
||||
FileOperation::Delete { path } => {
|
||||
wit_fa::FileOperation::Delete(wit_fa::DeleteFile { path: to_ipc(path) })
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
edits: a
|
||||
.edits
|
||||
.iter()
|
||||
.map(|(path, edits)| wit_fa::FileEdit {
|
||||
path: to_ipc(path),
|
||||
edits: edits
|
||||
.iter()
|
||||
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
open: a.open.as_deref().map(to_ipc),
|
||||
data: a.data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_from_wit(op: wit_fa::FileOperation) -> FileOperation {
|
||||
match op {
|
||||
wit_fa::FileOperation::Create(c) => {
|
||||
FileOperation::Create { path: c.path.into(), text: c.text, overwrite: c.overwrite }
|
||||
}
|
||||
wit_fa::FileOperation::Rename(r) => FileOperation::Rename { from: r.path.into(), to: r.new_path.into() },
|
||||
wit_fa::FileOperation::Delete(d) => FileOperation::Delete { path: d.path.into() },
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
@@ -1523,6 +1635,8 @@ mod tests {
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits: vec![(path.into(), Vec::new())],
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: None,
|
||||
};
|
||||
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
|
||||
|
||||
@@ -68,6 +68,49 @@ pub fn inside_root(root: &Path, path: &str) -> Result<bool, String> {
|
||||
Ok(resolve_real(&joined).starts_with(&root))
|
||||
}
|
||||
|
||||
/// Like [`resolve_in_root`] for a path that may not exist yet (a file about to
|
||||
/// be created) or that names a symlink itself (to delete or rename it): the
|
||||
/// directory part is resolved through symlinks, the final name is kept as is,
|
||||
/// and the result must stay inside `root`.
|
||||
pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let outside = || format!("{path}: access outside the project root is not allowed");
|
||||
if path.is_empty() {
|
||||
return Err("empty path".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let requested = Path::new(path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
let mut lexical = PathBuf::new();
|
||||
for part in joined.components() {
|
||||
match part {
|
||||
std::path::Component::ParentDir => {
|
||||
lexical.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
part => lexical.push(part),
|
||||
}
|
||||
}
|
||||
let (Some(name), Some(parent)) = (lexical.file_name(), lexical.parent()) else {
|
||||
return Err(outside());
|
||||
};
|
||||
let mut missing = Vec::new();
|
||||
let mut existing = parent;
|
||||
while !existing.exists() {
|
||||
let (Some(dir), Some(above)) = (existing.file_name(), existing.parent()) else {
|
||||
return Err(outside());
|
||||
};
|
||||
missing.push(dir);
|
||||
existing = above;
|
||||
}
|
||||
let mut real = existing.canonicalize().map_err(|e| format!("{path}: {e}"))?;
|
||||
real.extend(missing.into_iter().rev());
|
||||
real.push(name);
|
||||
if !real.starts_with(&root) {
|
||||
return Err(outside());
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 file that `resolve_in_root` already approved.
|
||||
pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
@@ -130,6 +173,36 @@ mod tests {
|
||||
assert!(inside_root(&root, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_paths_are_confined_to_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
let root_real = root.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(resolve_new_in_root(&root, "src/new/a.txt").unwrap(), root_real.join("src/new/a.txt"));
|
||||
assert_eq!(resolve_new_in_root(&root, "src/../b.txt").unwrap(), root_real.join("b.txt"));
|
||||
assert!(resolve_new_in_root(&root, "../x.txt").unwrap_err().contains("outside"));
|
||||
assert!(resolve_new_in_root(&root, "src/../../x.txt").unwrap_err().contains("outside"));
|
||||
assert!(resolve_new_in_root(&root, "/etc/new.txt").is_err());
|
||||
assert!(resolve_new_in_root(&root, "").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_is_named_not_followed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("target.txt"), "t").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("target.txt"), root.join("link.txt")).unwrap();
|
||||
std::os::unix::fs::symlink(tmp.path(), root.join("out")).unwrap();
|
||||
let root_real = root.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(resolve_new_in_root(&root, "link.txt").unwrap(), root_real.join("link.txt"));
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_cannot_escape() {
|
||||
|
||||
@@ -442,8 +442,21 @@ pub struct CodeAction {
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
|
||||
/// Applied before `edits`, in one workspace edit with them.
|
||||
pub operations: Vec<FileOperation>,
|
||||
/// Opened once the action is applied.
|
||||
pub open: Option<PathBuf>,
|
||||
/// The plugin's reference: the edits come from `resolve_code_action`.
|
||||
pub data: Option<String>,
|
||||
/// Listed by the `file-actions` export, which also resolves it.
|
||||
pub file_action: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileOperation {
|
||||
Create { path: PathBuf, text: String, overwrite: bool },
|
||||
Rename { from: PathBuf, to: PathBuf },
|
||||
Delete { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -986,3 +986,11 @@ pub(crate) mod inlay_bindings {
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `file-actions` export.
|
||||
pub(crate) mod file_actions_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "file-actions-provider", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6c6c9e25269f0c7d11aae8fd6c7ff66f457c8883b4f9ff19ae75f96c445ca3ff
|
||||
5752ebfddd9d2f2aef2ed7dfea7f39b3439f28b7f9bd09e2f32e40a65fc65f07
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
21fefee002afa17326ad22d9ff7e099500d07ab595035be3c6001ac15f4a08b0
|
||||
5f0ec195975e34065350eddabc252b830d2d7e8c87842be2d6310bff5a22eb67
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
d7aa5bd1a9fefa6e6c35534dfd507d0f4aa872bb128ab6a01c35fb8abdc474b2
|
||||
9551f337f0949c7249b022b8663333a66ae3534a7e0e112b37ed93d431960a54
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
44145d1953b8b1c5a5bd57a15f88d4b6e86c1d7f456a2cb24758d19aca9e152e
|
||||
600ef2642cfb359fea47e10a6a056e3e34f230681152aaff5077127da4325f10
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c324bb5591738282f63e90918be93cd7ac2a15f3c75fa105b60ead52a0504334
|
||||
c766b42405732d8d0dfec0955c6592e937928c96f2aedcbf317cddb55fccbdcb
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
95d0e2a562f204030c3b2128cd84825cc5d6b6e85514a75f70fb847a1170b355
|
||||
de40d28209203e58809622204dc17818a1c110f66515d3bb2dbca100861f544d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8687703fb1f2fb8d5ac187a6279a65d6cf1018a09262f6f5629bd4c903b26bdf
|
||||
1cde3e85cce52c303c9629dade79a4349e0963fb868947a8e53f74ae248efd24
|
||||
@@ -7,6 +7,7 @@ wit_bindgen::generate!({
|
||||
world misbehave {
|
||||
include openidea:plugin/[email protected];
|
||||
export openidea:plugin/[email protected];
|
||||
export openidea:plugin/[email protected];
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
@@ -189,4 +190,64 @@ impl providers::Guest for Misbehave {
|
||||
}
|
||||
}
|
||||
|
||||
/// `files`: one action that creates, renames and deletes files, then opens
|
||||
/// one, and one listed with `data` only, whose file `resolve` creates.
|
||||
impl exports::openidea::plugin::file_actions::Guest for Misbehave {
|
||||
fn code_actions(
|
||||
doc: exports::openidea::plugin::file_actions::Document,
|
||||
_selection: types::Range,
|
||||
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
|
||||
if doc.text != "files" {
|
||||
return Vec::new();
|
||||
}
|
||||
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir).to_owned();
|
||||
vec![
|
||||
FileAction {
|
||||
title: "Reorganise".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: vec![
|
||||
FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/created.txt"),
|
||||
text: "hello".into(),
|
||||
overwrite: false,
|
||||
}),
|
||||
FileOperation::Rename(RenameFile {
|
||||
path: format!("{dir}/old.txt"),
|
||||
new_path: format!("{dir}/renamed.txt"),
|
||||
}),
|
||||
FileOperation::Delete(DeleteFile { path: format!("{dir}/gone.txt") }),
|
||||
],
|
||||
edits: Vec::new(),
|
||||
open: Some(format!("{dir}/created.txt")),
|
||||
data: None,
|
||||
},
|
||||
FileAction {
|
||||
title: "Create later".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: Vec::new(),
|
||||
edits: Vec::new(),
|
||||
open: None,
|
||||
data: Some(dir),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
_doc: exports::openidea::plugin::file_actions::Document,
|
||||
action: exports::openidea::plugin::file_actions::FileAction,
|
||||
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
|
||||
let dir = action.data.clone().ok_or_else(|| types::Error::Invalid("no data".into()))?;
|
||||
let operations = vec![FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/later.txt"),
|
||||
text: "later".into(),
|
||||
overwrite: false,
|
||||
})];
|
||||
Ok(exports::openidea::plugin::file_actions::FileAction { operations, data: None, ..action })
|
||||
}
|
||||
}
|
||||
|
||||
export!(Misbehave);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
aa2d8d38e49d10c03f6c639ce45b2a26b772c9f106a70547e94a9d46d8eef662
|
||||
8c7e44efd87d5463fc2333adffe051c9937e80e06869c2f5ffe8b49daa49ec0d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
eb5a36b16bf3ac0f98c1a10d8c7199d47b39d7448a5f4da2a5b47a111b7fd694
|
||||
295f568f9bc12eb0f005771c611c85d1e3622a32548ceb04e10fd395cc61fa82
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
d7840fe3f2f2b21a2cfbdb7c874ebc3214d1a81e324853dd47b04d914434f050
|
||||
5d510fc9bdfe868bd392a8a0a46739f23db3db38bd9adb43d6b3c05d0c125c1d
|
||||
+10
-1
@@ -480,7 +480,16 @@ can be a `snippet` (`$1`, `${1:name}`, `$0`), match on `filter_text` instead
|
||||
of its label, and bring `additional_edits` elsewhere in the document (an
|
||||
import). A code action can leave `edits` empty and set `data`: its edits are
|
||||
then computed by `resolve_code_action` when the user picks it, under the
|
||||
command budget instead of the 50 ms. Problems (`publish_diagnostics`) can name
|
||||
command budget instead of the 50 ms. A quick fix that creates, renames or
|
||||
deletes files implements `file_actions::FileActions` too
|
||||
(`register_plugin!(T, providers, file_actions)`; the `code-actions` kind
|
||||
covers both): a `FileAction` carries `operations` (`FileOperation::Create {
|
||||
path, text, overwrite }`, `Rename`, `Delete`) and `edits`, applied as one
|
||||
workspace edit, so a failure changes nothing and one undo reverses it. A path
|
||||
outside the project root disables the action; `open` shows a file once the
|
||||
action is applied. Like a code action, it can leave `operations` and `edits`
|
||||
empty and set `data`: `resolve_file_action` computes them when the user picks
|
||||
it. File actions are new in API 1.3. Problems (`publish_diagnostics`) can name
|
||||
the tool that found them (`source`) and be `unnecessary` (faded) or
|
||||
`deprecated` (struck through).
|
||||
Editor providers run under the edit-path budget: 50 ms, after which the
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
43d43f7a317c6fb8d5cca1b2a07f0e8dae05be6f5fcfc002527a409c519deb4f
|
||||
df2aad3aa261a7cc4059c92666d221d1599306d47049b7b97a7bba263c6f51e5
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
881d4ad1ac25784507c836c220fc09bdcdf45193f13538e928b8b3619b548552
|
||||
ef4128ec97e203c3dd0c49f3bcdbfaa5eb2be59123dc4628fc31d52dc61b3186
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b029de3ddd2add1812b3071d407c59a97463a701b63c22232450b19a9f1d6557
|
||||
14dc3f5a5356ed82a84e7802c822545cba412d3e4a3b614aab6f4f7d3686584c
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
4952cbef6094c109c3ca9637c8df7d47836b85632c450164f8f70b6b5f22d4e5
|
||||
936d9fdad9752fa34a4a7ac18014c7bfc7cbd1b22f71b1501c667f4b83faad0b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
34c03e490bea652f4e28fe2fb785dd9a2b0421f46c0015273793c14e074867f1
|
||||
7b559f3febbd3acd56f08d530209212ab2d7da1c794346e2beaf0f19d0a3227f
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
d18f6687bc32a7a06c32ee81dd6e0c88e3acefd29934c9fa7271179d94fed194
|
||||
b2e111085be32495ac82055e8258f8841a4a44979b4052ef102bb5726efc90e5
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
444dc979be1b8194631303c3ad6399b876ee9229d1a8554681406fd39164f604
|
||||
522aa68db4454f9c5511a90c74c6c26a8b95922e6725d9e411ddde7db998fa71
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
6f9412fce5e1a3d7453b0a1089d50544d49a3b1e8f022a5a1221ea597d2fd7dd
|
||||
2f23364367583f9101cea4fc8f4ff190038a8b04c0f3b07a0f145c917807c237
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a861d2b792c06dbac0476fa0b99321296da52f792ff340b1fd25de3b0e6422de
|
||||
028e26a83c42ee3e01781e0006e857b1234c8dd655c1619de79aaee116998e9f
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ccd7eab004cd2f767f6be21ce272f647e20695ceff8694293beff8ce8a06c646
|
||||
9a19f399c1aa5fbc4d80aa7d11c5b73d968d0664bbc12dea8488d5fcab846075
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
8ae2a72ec23163926bac3914f4b27a429598982cd12f42f985df10e920942fdb
|
||||
73b65742330387b7dbf498c6ec275ab41a55475243d771c1274de70b939c327f
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Quick fixes that create, rename or delete files: implement
|
||||
//! [`FileActions`] and register with `register_plugin!(T, file_actions)` (with
|
||||
//! any other exports, e.g. `register_plugin!(T, providers, file_actions)`).
|
||||
//! Declare the `code-actions` kind in `[[contributes.providers]]`; the actions
|
||||
//! are listed with those of [`crate::providers::Providers::code_actions`] and
|
||||
//! have the same budget.
|
||||
|
||||
use crate::providers::Document;
|
||||
use crate::types::{Range, TextEdit};
|
||||
|
||||
/// A file change a [`FileAction`] makes. Paths are absolute or relative to the
|
||||
/// project root, and stay inside it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileOperation {
|
||||
/// Fails when the file exists, unless `overwrite`.
|
||||
Create {
|
||||
path: String,
|
||||
text: String,
|
||||
overwrite: bool,
|
||||
},
|
||||
Rename {
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
/// Deletes the file; undoing the action restores it.
|
||||
Delete {
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Applied as one workspace edit: `operations` in order, then `edits`. If any
|
||||
/// step fails nothing changes, and one undo reverses all of it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FileAction {
|
||||
pub title: String,
|
||||
/// `quickfix`, `refactor`, …
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub operations: Vec<FileOperation>,
|
||||
/// (path, edits) per file; may target files the operations create.
|
||||
pub edits: Vec<(String, Vec<TextEdit>)>,
|
||||
/// Opened once the action is applied.
|
||||
pub open: Option<String>,
|
||||
/// The plugin's own reference: with it and no `operations` or `edits`,
|
||||
/// they come from [`FileActions::resolve_file_action`] when the user picks
|
||||
/// the action.
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
pub trait FileActions {
|
||||
fn file_actions(&mut self, doc: &Document, selection: Range) -> Vec<FileAction>;
|
||||
|
||||
/// The operations and edits of an action [`FileActions::file_actions`]
|
||||
/// returned with `data`, when the user picks it (the command budget
|
||||
/// applies, not 50 ms).
|
||||
fn resolve_file_action(&mut self, _doc: &Document, action: FileAction) -> Result<FileAction, crate::Error> {
|
||||
Ok(action)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
pub mod commit;
|
||||
pub mod details;
|
||||
pub mod file_actions;
|
||||
pub mod host;
|
||||
pub mod hover;
|
||||
pub mod indexing;
|
||||
@@ -197,6 +198,17 @@ pub mod inlay_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[doc(hidden)]
|
||||
pub mod file_actions_bindings {
|
||||
wit_bindgen::generate!({
|
||||
world: "file-actions-provider", path: "wit", pub_export_macro: true,
|
||||
export_macro_name: "__export_file_actions_world",
|
||||
default_bindings_module: "openidea_plugin::file_actions_bindings",
|
||||
with: { "openidea:plugin/[email protected]": crate::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
/// The plugin API version this SDK targets.
|
||||
pub const API_VERSION: &str = "1.3";
|
||||
|
||||
@@ -254,6 +266,9 @@ macro_rules! __optional_exports {
|
||||
(semantic_tokens, $plugin:ty, $component:ident) => {
|
||||
$crate::__semantic_tokens_exports!($plugin, $component);
|
||||
};
|
||||
(file_actions, $plugin:ty, $component:ident) => {
|
||||
$crate::__file_actions_exports!($plugin, $component);
|
||||
};
|
||||
(details, $plugin:ty, $component:ident) => {
|
||||
$crate::__details_exports!($plugin, $component);
|
||||
};
|
||||
@@ -383,6 +398,32 @@ macro_rules! __views_exports {
|
||||
};
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __file_actions_exports {
|
||||
($plugin:ty, $component:ident) => {
|
||||
impl $crate::file_actions_bindings::exports::openidea::plugin::file_actions::Guest for $component {
|
||||
fn code_actions(
|
||||
doc: $crate::file_actions_bindings::exports::openidea::plugin::file_actions::Document,
|
||||
selection: $crate::bindings::openidea::plugin::types::Range,
|
||||
) -> ::std::vec::Vec<$crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction> {
|
||||
$crate::__rt::file_actions::<$plugin>(doc, selection)
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
doc: $crate::file_actions_bindings::exports::openidea::plugin::file_actions::Document,
|
||||
action: $crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction,
|
||||
) -> ::core::result::Result<
|
||||
$crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction,
|
||||
$crate::bindings::openidea::plugin::types::Error,
|
||||
> {
|
||||
$crate::__rt::resolve_file_action::<$plugin>(doc, action)
|
||||
}
|
||||
}
|
||||
$crate::file_actions_bindings::__export_file_actions_world!($component with_types_in $crate::file_actions_bindings);
|
||||
};
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __indexing_exports {
|
||||
@@ -772,6 +813,97 @@ pub mod __rt {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn file_actions<T: Plugin + crate::file_actions::FileActions + Default>(
|
||||
d: crate::file_actions_bindings::exports::openidea::plugin::file_actions::Document,
|
||||
sel: b::types::Range,
|
||||
) -> Vec<crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction> {
|
||||
let doc = file_doc(d);
|
||||
let actions = with::<T, _>(|x| x.file_actions(&doc, crate::host::__range_from(sel)));
|
||||
actions.into_iter().map(file_action_out).collect()
|
||||
}
|
||||
|
||||
pub fn resolve_file_action<T: Plugin + crate::file_actions::FileActions + Default>(
|
||||
d: crate::file_actions_bindings::exports::openidea::plugin::file_actions::Document,
|
||||
action: crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction,
|
||||
) -> Result<crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction, b::types::Error>
|
||||
{
|
||||
let doc = file_doc(d);
|
||||
with::<T, _>(|x| x.resolve_file_action(&doc, file_action_in(action))).map(file_action_out).map_err(error_out)
|
||||
}
|
||||
|
||||
fn file_doc(d: crate::file_actions_bindings::exports::openidea::plugin::file_actions::Document) -> p::Document {
|
||||
p::Document { path: d.path, language: d.language, version: d.version, text: d.text }
|
||||
}
|
||||
|
||||
fn file_action_out(
|
||||
a: crate::file_actions::FileAction,
|
||||
) -> crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction {
|
||||
use crate::file_actions as f;
|
||||
use crate::file_actions_bindings::exports::openidea::plugin::file_actions as w;
|
||||
w::FileAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
preferred: a.preferred,
|
||||
operations: a
|
||||
.operations
|
||||
.into_iter()
|
||||
.map(|op| match op {
|
||||
f::FileOperation::Create { path, text, overwrite } => {
|
||||
w::FileOperation::Create(w::CreateFile { path, text, overwrite })
|
||||
}
|
||||
f::FileOperation::Rename { from, to } => {
|
||||
w::FileOperation::Rename(w::RenameFile { path: from, new_path: to })
|
||||
}
|
||||
f::FileOperation::Delete { path } => w::FileOperation::Delete(w::DeleteFile { path }),
|
||||
})
|
||||
.collect(),
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| w::FileEdit { path, edits: edits.into_iter().map(edit_out).collect() })
|
||||
.collect(),
|
||||
open: a.open,
|
||||
data: a.data,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_in(
|
||||
a: crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction,
|
||||
) -> crate::file_actions::FileAction {
|
||||
use crate::file_actions as f;
|
||||
use crate::file_actions_bindings::exports::openidea::plugin::file_actions as w;
|
||||
f::FileAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
preferred: a.preferred,
|
||||
operations: a
|
||||
.operations
|
||||
.into_iter()
|
||||
.map(|op| match op {
|
||||
w::FileOperation::Create(c) => {
|
||||
f::FileOperation::Create { path: c.path, text: c.text, overwrite: c.overwrite }
|
||||
}
|
||||
w::FileOperation::Rename(r) => f::FileOperation::Rename { from: r.path, to: r.new_path },
|
||||
w::FileOperation::Delete(d) => f::FileOperation::Delete { path: d.path },
|
||||
})
|
||||
.collect(),
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let edits = f
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|e| crate::TextEdit { range: crate::host::__range_from(e.range), text: e.text })
|
||||
.collect();
|
||||
(f.path, edits)
|
||||
})
|
||||
.collect(),
|
||||
open: a.open,
|
||||
data: a.data,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- views ----
|
||||
|
||||
use crate::views as v;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Optional export (world `plugin-file-actions`): quick fixes and
|
||||
/// refactorings that create, rename or delete files, next to the text edits
|
||||
/// `providers.code-actions` offers. Declare the `code-actions` provider kind in
|
||||
/// `[[contributes.providers]]`; the host calls `code-actions` here with the same
|
||||
/// budget and lists the results with the others.
|
||||
interface file-actions {
|
||||
use types.{error, range, text-edit};
|
||||
|
||||
/// The document the actions are for (the editor's text while it is open).
|
||||
record document {
|
||||
/// Absolute path.
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// Text edits to one file.
|
||||
record file-edit {
|
||||
path: string,
|
||||
edits: list<text-edit>,
|
||||
}
|
||||
|
||||
/// A file change, applied before the action's text edits.
|
||||
variant file-operation {
|
||||
/// Fails when the file exists, unless `overwrite`.
|
||||
create(create-file),
|
||||
rename(rename-file),
|
||||
/// Deletes the file; undoing the action restores it.
|
||||
delete(delete-file),
|
||||
}
|
||||
|
||||
/// A new file with its text; missing parent directories are created.
|
||||
record create-file {
|
||||
path: string,
|
||||
text: string,
|
||||
overwrite: bool,
|
||||
}
|
||||
|
||||
/// Moves `path` to `new-path`.
|
||||
record rename-file {
|
||||
path: string,
|
||||
new-path: string,
|
||||
}
|
||||
|
||||
/// The file to delete.
|
||||
record delete-file {
|
||||
path: string,
|
||||
}
|
||||
|
||||
/// A code action that changes files. Paths are absolute or relative to the
|
||||
/// project root, and stay inside it: a path outside disables the action.
|
||||
record file-action {
|
||||
title: string,
|
||||
/// `quickfix`, `refactor`, …
|
||||
kind: option<string>,
|
||||
preferred: bool,
|
||||
/// Applied first, in order, as one workspace edit with `edits`: if
|
||||
/// any operation or edit fails, nothing is applied, and one undo
|
||||
/// reverses all of it.
|
||||
operations: list<file-operation>,
|
||||
/// Applied after the operations; may target files they create.
|
||||
edits: list<file-edit>,
|
||||
/// Opened once the action is applied.
|
||||
open: option<string>,
|
||||
/// The plugin's own reference: with it and no `operations` or `edits`,
|
||||
/// they are computed by `resolve` once the user picks the action.
|
||||
data: option<string>,
|
||||
}
|
||||
|
||||
/// Actions for `selection`, under the edit-path budget.
|
||||
code-actions: func(doc: document, selection: range) -> list<file-action>;
|
||||
/// The operations and edits of an action listed with `data` and nothing
|
||||
/// to apply, once the user picks it (the command budget applies, not the
|
||||
/// editor's 50 ms).
|
||||
resolve: func(doc: document, action: file-action) -> result<file-action, error>;
|
||||
}
|
||||
@@ -86,6 +86,12 @@ world plugin-commit-checks {
|
||||
export commit-checker;
|
||||
}
|
||||
|
||||
/// A plugin with file-changing code actions (`file-actions`, next to `[[contributes.providers]]`).
|
||||
world plugin-file-actions {
|
||||
include plugin;
|
||||
export file-actions;
|
||||
}
|
||||
|
||||
/// Only the `syntax-provider` export: guests generate it next to `plugin`
|
||||
/// (the component tools merge both into one component).
|
||||
world syntax {
|
||||
@@ -129,3 +135,8 @@ world details {
|
||||
world commit-checks {
|
||||
export commit-checker;
|
||||
}
|
||||
|
||||
/// Only the `file-actions` export, generated next to `plugin` like `syntax`.
|
||||
world file-actions-provider {
|
||||
export file-actions;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Optional export (world `plugin-file-actions`): quick fixes and
|
||||
/// refactorings that create, rename or delete files, next to the text edits
|
||||
/// `providers.code-actions` offers. Declare the `code-actions` provider kind in
|
||||
/// `[[contributes.providers]]`; the host calls `code-actions` here with the same
|
||||
/// budget and lists the results with the others.
|
||||
interface file-actions {
|
||||
use types.{error, range, text-edit};
|
||||
|
||||
/// The document the actions are for (the editor's text while it is open).
|
||||
record document {
|
||||
/// Absolute path.
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// Text edits to one file.
|
||||
record file-edit {
|
||||
path: string,
|
||||
edits: list<text-edit>,
|
||||
}
|
||||
|
||||
/// A file change, applied before the action's text edits.
|
||||
variant file-operation {
|
||||
/// Fails when the file exists, unless `overwrite`.
|
||||
create(create-file),
|
||||
rename(rename-file),
|
||||
/// Deletes the file; undoing the action restores it.
|
||||
delete(delete-file),
|
||||
}
|
||||
|
||||
/// A new file with its text; missing parent directories are created.
|
||||
record create-file {
|
||||
path: string,
|
||||
text: string,
|
||||
overwrite: bool,
|
||||
}
|
||||
|
||||
/// Moves `path` to `new-path`.
|
||||
record rename-file {
|
||||
path: string,
|
||||
new-path: string,
|
||||
}
|
||||
|
||||
/// The file to delete.
|
||||
record delete-file {
|
||||
path: string,
|
||||
}
|
||||
|
||||
/// A code action that changes files. Paths are absolute or relative to the
|
||||
/// project root, and stay inside it: a path outside disables the action.
|
||||
record file-action {
|
||||
title: string,
|
||||
/// `quickfix`, `refactor`, …
|
||||
kind: option<string>,
|
||||
preferred: bool,
|
||||
/// Applied first, in order, as one workspace edit with `edits`: if
|
||||
/// any operation or edit fails, nothing is applied, and one undo
|
||||
/// reverses all of it.
|
||||
operations: list<file-operation>,
|
||||
/// Applied after the operations; may target files they create.
|
||||
edits: list<file-edit>,
|
||||
/// Opened once the action is applied.
|
||||
open: option<string>,
|
||||
/// The plugin's own reference: with it and no `operations` or `edits`,
|
||||
/// they are computed by `resolve` once the user picks the action.
|
||||
data: option<string>,
|
||||
}
|
||||
|
||||
/// Actions for `selection`, under the edit-path budget.
|
||||
code-actions: func(doc: document, selection: range) -> list<file-action>;
|
||||
/// The operations and edits of an action listed with `data` and nothing
|
||||
/// to apply, once the user picks it (the command budget applies, not the
|
||||
/// editor's 50 ms).
|
||||
resolve: func(doc: document, action: file-action) -> result<file-action, error>;
|
||||
}
|
||||
@@ -86,6 +86,12 @@ world plugin-commit-checks {
|
||||
export commit-checker;
|
||||
}
|
||||
|
||||
/// A plugin with file-changing code actions (`file-actions`, next to `[[contributes.providers]]`).
|
||||
world plugin-file-actions {
|
||||
include plugin;
|
||||
export file-actions;
|
||||
}
|
||||
|
||||
/// Only the `syntax-provider` export: guests generate it next to `plugin`
|
||||
/// (the component tools merge both into one component).
|
||||
world syntax {
|
||||
@@ -129,3 +135,8 @@ world details {
|
||||
world commit-checks {
|
||||
export commit-checker;
|
||||
}
|
||||
|
||||
/// Only the `file-actions` export, generated next to `plugin` like `syntax`.
|
||||
world file-actions-provider {
|
||||
export file-actions;
|
||||
}
|
||||
Reference in new issue
Block a user