diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index c4a14fa8c..6dc3dbc5b 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -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, @@ -85,7 +86,12 @@ const PLUGIN_SERVER: ServerId = ServerId(0); #[derive(serde::Serialize, serde::Deserialize)] struct PluginActionRef { plugin: String, - data: String, + data: Option, + /// Opened once the action is applied. + open: Option, + /// 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 = 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, Option), 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 { @@ -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::(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| 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: "

".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 == "

") + ); + 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"); diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index 2703ca127..3a6697da5 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -169,7 +169,7 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result Vec { let d = Arc::new(doc.clone()); self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| { diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index c4807d701..74ed0de20 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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); diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 1725a06fc..1b417b2b0 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -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; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index aabc1d6ee..d27449533 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -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, providers: Option, indexer: Option, + file_actions: Option, views: Option, semantic: Option, details: Option, @@ -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, 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 { + 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, max: usize) -> Option { 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); diff --git a/crates/ide-plugin/src/sandbox.rs b/crates/ide-plugin/src/sandbox.rs index 1756b1f9a..7d966ff16 100644 --- a/crates/ide-plugin/src/sandbox.rs +++ b/crates/ide-plugin/src/sandbox.rs @@ -68,6 +68,49 @@ pub fn inside_root(root: &Path, path: &str) -> Result { 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 { + 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 { 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() { diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 6287b8e88..d071a247f 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -442,8 +442,21 @@ pub struct CodeAction { pub kind: Option, pub preferred: bool, pub edits: Vec<(PathBuf, Vec)>, + /// Applied before `edits`, in one workspace edit with them. + pub operations: Vec, + /// Opened once the action is applied. + pub open: Option, /// The plugin's reference: the edits come from `resolve_code_action`. pub data: Option, + /// 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)] diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 44eaef7a1..368510666 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -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 }, + }); +} diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 8f307470c..1e0a4b5d6 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index a1ba8694c..afeba1992 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -6c6c9e25269f0c7d11aae8fd6c7ff66f457c8883b4f9ff19ae75f96c445ca3ff +5752ebfddd9d2f2aef2ed7dfea7f39b3439f28b7f9bd09e2f32e40a65fc65f07 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index ba88bd79d..015e757dc 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index a92345f28..1c16ed578 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -21fefee002afa17326ad22d9ff7e099500d07ab595035be3c6001ac15f4a08b0 +5f0ec195975e34065350eddabc252b830d2d7e8c87842be2d6310bff5a22eb67 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index efcad8abe..f5c370a19 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index a31a00868..75d41c99a 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -d7aa5bd1a9fefa6e6c35534dfd507d0f4aa872bb128ab6a01c35fb8abdc474b2 +9551f337f0949c7249b022b8663333a66ae3534a7e0e112b37ed93d431960a54 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index ed7a7ecb6..596b975d1 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 9bf5f1550..d4079b4f3 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -44145d1953b8b1c5a5bd57a15f88d4b6e86c1d7f456a2cb24758d19aca9e152e +600ef2642cfb359fea47e10a6a056e3e34f230681152aaff5077127da4325f10 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 6be609a4e..32a057954 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index 0b0a14901..fc837c71a 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -c324bb5591738282f63e90918be93cd7ac2a15f3c75fa105b60ead52a0504334 +c766b42405732d8d0dfec0955c6592e937928c96f2aedcbf317cddb55fccbdcb diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 68beedeee..f6d907d3d 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index af98bd83d..278f01c95 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -95d0e2a562f204030c3b2128cd84825cc5d6b6e85514a75f70fb847a1170b355 +de40d28209203e58809622204dc17818a1c110f66515d3bb2dbca100861f544d diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index d6f2fc30e..db85e4fe3 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 42ca65815..9b46a386e 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -8687703fb1f2fb8d5ac187a6279a65d6cf1018a09262f6f5629bd4c903b26bdf +1cde3e85cce52c303c9629dade79a4349e0963fb868947a8e53f74ae248efd24 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 491816f4c..3fc1b81f9 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -7,6 +7,7 @@ wit_bindgen::generate!({ world misbehave { include openidea:plugin/plugin-syntax@1.3.0; export openidea:plugin/providers@1.3.0; + export openidea:plugin/file-actions@1.3.0; } ", 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 { + 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 { + 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); diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index e85457d1d..006add95d 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 58b7a457b..fecbd15f9 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -aa2d8d38e49d10c03f6c639ce45b2a26b772c9f106a70547e94a9d46d8eef662 +8c7e44efd87d5463fc2333adffe051c9937e80e06869c2f5ffe8b49daa49ec0d diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index 36c158c77..b732a322d 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 4d75ad1d4..ed6a8ea46 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -eb5a36b16bf3ac0f98c1a10d8c7199d47b39d7448a5f4da2a5b47a111b7fd694 +295f568f9bc12eb0f005771c611c85d1e3622a32548ceb04e10fd395cc61fa82 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index a3b8be47f..8e4fba2ca 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 603bbcedb..87140907b 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -d7840fe3f2f2b21a2cfbdb7c874ebc3214d1a81e324853dd47b04d914434f050 +5d510fc9bdfe868bd392a8a0a46739f23db3db38bd9adb43d6b3c05d0c125c1d diff --git a/plugins/README.md b/plugins/README.md index 6a1b306f3..9bada5b16 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index c9697cd98..edb14100e 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 0267b45f3..07b79acd0 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -43d43f7a317c6fb8d5cca1b2a07f0e8dae05be6f5fcfc002527a409c519deb4f +df2aad3aa261a7cc4059c92666d221d1599306d47049b7b97a7bba263c6f51e5 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 5d0d07fff..dced97eff 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index b486e9c11..ab7fc8de2 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -881d4ad1ac25784507c836c220fc09bdcdf45193f13538e928b8b3619b548552 +ef4128ec97e203c3dd0c49f3bcdbfaa5eb2be59123dc4628fc31d52dc61b3186 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 4f93512a0..e2211c9ed 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 3093f1ac3..1e822e99b 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -b029de3ddd2add1812b3071d407c59a97463a701b63c22232450b19a9f1d6557 +14dc3f5a5356ed82a84e7802c822545cba412d3e4a3b614aab6f4f7d3686584c diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index b238f5dd6..52d7b9fe4 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 3a1d585bd..1b79b8a4e 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -4952cbef6094c109c3ca9637c8df7d47836b85632c450164f8f70b6b5f22d4e5 +936d9fdad9752fa34a4a7ac18014c7bfc7cbd1b22f71b1501c667f4b83faad0b diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 26e53bd0f..4ed139ef8 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 4a8c56d99..678c34a17 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -34c03e490bea652f4e28fe2fb785dd9a2b0421f46c0015273793c14e074867f1 +7b559f3febbd3acd56f08d530209212ab2d7da1c794346e2beaf0f19d0a3227f diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 16096aafa..dfbdd68c6 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 5f2f41137..fc8a91825 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -d18f6687bc32a7a06c32ee81dd6e0c88e3acefd29934c9fa7271179d94fed194 +b2e111085be32495ac82055e8258f8841a4a44979b4052ef102bb5726efc90e5 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 23d123cef..266959871 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 9d2cd989e..12094f2f7 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -444dc979be1b8194631303c3ad6399b876ee9229d1a8554681406fd39164f604 +522aa68db4454f9c5511a90c74c6c26a8b95922e6725d9e411ddde7db998fa71 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 52c6fdb6a..66fc3eebd 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 9ea91bfca..a97db01e5 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -6f9412fce5e1a3d7453b0a1089d50544d49a3b1e8f022a5a1221ea597d2fd7dd +2f23364367583f9101cea4fc8f4ff190038a8b04c0f3b07a0f145c917807c237 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 6c56e4a05..beb27e62f 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 122edc5da..f6ed5943c 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -a861d2b792c06dbac0476fa0b99321296da52f792ff340b1fd25de3b0e6422de +028e26a83c42ee3e01781e0006e857b1234c8dd655c1619de79aaee116998e9f diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 81ef22be5..1c1896f2b 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index a042f606c..4f4a414a0 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -ccd7eab004cd2f767f6be21ce272f647e20695ceff8694293beff8ce8a06c646 +9a19f399c1aa5fbc4d80aa7d11c5b73d968d0664bbc12dea8488d5fcab846075 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 619f9374d..15934b232 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 624214523..2b16319be 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -8ae2a72ec23163926bac3914f4b27a429598982cd12f42f985df10e920942fdb +73b65742330387b7dbf498c6ec275ab41a55475243d771c1274de70b939c327f diff --git a/sdk/rust/openidea-plugin/src/file_actions.rs b/sdk/rust/openidea-plugin/src/file_actions.rs new file mode 100644 index 000000000..b0d911df7 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/file_actions.rs @@ -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, + pub preferred: bool, + pub operations: Vec, + /// (path, edits) per file; may target files the operations create. + pub edits: Vec<(String, Vec)>, + /// Opened once the action is applied. + pub open: Option, + /// 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, +} + +pub trait FileActions { + fn file_actions(&mut self, doc: &Document, selection: Range) -> Vec; + + /// 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 { + Ok(action) + } +} diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 3af96e16c..853d85ad0 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -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/types@1.3.0": 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( + d: crate::file_actions_bindings::exports::openidea::plugin::file_actions::Document, + sel: b::types::Range, + ) -> Vec { + let doc = file_doc(d); + let actions = with::(|x| x.file_actions(&doc, crate::host::__range_from(sel))); + actions.into_iter().map(file_action_out).collect() + } + + pub fn resolve_file_action( + d: crate::file_actions_bindings::exports::openidea::plugin::file_actions::Document, + action: crate::file_actions_bindings::exports::openidea::plugin::file_actions::FileAction, + ) -> Result + { + let doc = file_doc(d); + with::(|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; diff --git a/sdk/rust/openidea-plugin/wit/file-actions.wit b/sdk/rust/openidea-plugin/wit/file-actions.wit new file mode 100644 index 000000000..2d08b9b06 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/file-actions.wit @@ -0,0 +1,79 @@ +package openidea:plugin@1.3.0; + +/// 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, + } + + /// 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, + 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, + /// Applied after the operations; may target files they create. + edits: list, + /// Opened once the action is applied. + open: option, + /// 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, + } + + /// Actions for `selection`, under the edit-path budget. + code-actions: func(doc: document, selection: range) -> list; + /// 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; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 19c6dc9e1..ad07bbacc 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -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; +} diff --git a/wit/file-actions.wit b/wit/file-actions.wit new file mode 100644 index 000000000..2d08b9b06 --- /dev/null +++ b/wit/file-actions.wit @@ -0,0 +1,79 @@ +package openidea:plugin@1.3.0; + +/// 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, + } + + /// 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, + 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, + /// Applied after the operations; may target files they create. + edits: list, + /// Opened once the action is applied. + open: option, + /// 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, + } + + /// Actions for `selection`, under the edit-path budget. + code-actions: func(doc: document, selection: range) -> list; + /// 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; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index 19c6dc9e1..ad07bbacc 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -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; +}