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:
Arnaud Ritti authored and GitHub committed 2026-11-07 21:24:50 +08:00
1 parent 1ab4edc0d7
commit 25d930b780
58 files changed
+896 -52

No files matched your search

+186 -21
View File
@@ -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");
+7 -1
View File
@@ -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| {
+24
View File
@@ -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);
+1 -1
View File
@@ -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;
+121 -7
View File
@@ -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);
+73
View File
@@ -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() {
+13
View File
@@ -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)]
+8
View File
@@ -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
+61
View File
@@ -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
View File
@@ -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
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
43d43f7a317c6fb8d5cca1b2a07f0e8dae05be6f5fcfc002527a409c519deb4f
df2aad3aa261a7cc4059c92666d221d1599306d47049b7b97a7bba263c6f51e5
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
881d4ad1ac25784507c836c220fc09bdcdf45193f13538e928b8b3619b548552
ef4128ec97e203c3dd0c49f3bcdbfaa5eb2be59123dc4628fc31d52dc61b3186
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b029de3ddd2add1812b3071d407c59a97463a701b63c22232450b19a9f1d6557
14dc3f5a5356ed82a84e7802c822545cba412d3e4a3b614aab6f4f7d3686584c
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
4952cbef6094c109c3ca9637c8df7d47836b85632c450164f8f70b6b5f22d4e5
936d9fdad9752fa34a4a7ac18014c7bfc7cbd1b22f71b1501c667f4b83faad0b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
34c03e490bea652f4e28fe2fb785dd9a2b0421f46c0015273793c14e074867f1
7b559f3febbd3acd56f08d530209212ab2d7da1c794346e2beaf0f19d0a3227f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d18f6687bc32a7a06c32ee81dd6e0c88e3acefd29934c9fa7271179d94fed194
b2e111085be32495ac82055e8258f8841a4a44979b4052ef102bb5726efc90e5
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
444dc979be1b8194631303c3ad6399b876ee9229d1a8554681406fd39164f604
522aa68db4454f9c5511a90c74c6c26a8b95922e6725d9e411ddde7db998fa71
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6f9412fce5e1a3d7453b0a1089d50544d49a3b1e8f022a5a1221ea597d2fd7dd
2f23364367583f9101cea4fc8f4ff190038a8b04c0f3b07a0f145c917807c237
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a861d2b792c06dbac0476fa0b99321296da52f792ff340b1fd25de3b0e6422de
028e26a83c42ee3e01781e0006e857b1234c8dd655c1619de79aaee116998e9f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ccd7eab004cd2f767f6be21ce272f647e20695ceff8694293beff8ce8a06c646
9a19f399c1aa5fbc4d80aa7d11c5b73d968d0664bbc12dea8488d5fcab846075
Binary file not shown.
+1 -1
View File
@@ -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)
}
}
+132
View File
@@ -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>;
}
+11
View File
@@ -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;
}
+79
View File
@@ -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>;
}
+11
View File
@@ -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;
}