feat(plugins): leave room in plugin API 1.0 for what will grow

Released 1.x types never change, so the sets and records that will have
to grow are opened up before the API is frozen:

- Event kinds and action sources are strings; `event`, view `body`,
  `view-event` and `field-value` get an `other(extension)` case (a kind and
  JSON data) that carries later cases to older plugins and IDEs. An IDE
  refuses an event kind it doesn't know, says a view body needs a newer
  IDE, and leaves out unknown form fields.
- Completion items can be snippets, match on `filter-text` and bring
  `additional-edits` (imports). Code actions can carry `data` instead of
  edits; the new `resolve-code-action` export computes them when the user
  picks the action, under the command budget, through the existing resolve
  path.
- Diagnostics name their `source`, carry `tags` (`unnecessary`,
  `deprecated`) and `related` locations.
- The SDK's enums are `#[non_exhaustive]` with `Other` variants;
  `Diagnostic::new`, `Providers::resolve_code_action`.
- README: 1.x calls stay synchronous and pass documents whole; async and
  incremental sync come later behind the host adapter or a new interface.

Official plugins and fixtures rebuilt.
This commit is contained in:
l0ng-ai
2026-11-04 20:10:09 +08:00
parent e3c7f362c4
commit 7c71cf8b6a
56 changed files with 698 additions and 210 deletions
+67 -11
View File
@@ -76,9 +76,17 @@ async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static)
tokio::task::spawn_blocking(f).await.ok()
}
/// Items from plugins carry no server (resolve and commands don't apply).
/// Items from plugins carry no server: commands don't apply, and a code
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
const PLUGIN_SERVER: ServerId = ServerId(0);
/// `data` of a plugin's code action whose edits it computes when picked.
#[derive(serde::Serialize, serde::Deserialize)]
struct PluginActionRef {
plugin: String,
data: String,
}
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
Some(match kind {
@@ -120,14 +128,14 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
sort_text: item.sort_text,
filter_text: None,
filter_text: item.filter_text,
preselect: false,
deprecated: false,
insert_text: item.insert_text,
is_snippet: false,
is_snippet: item.snippet,
replace_range: item.replace,
replace_range_tab: None,
additional_edits: Vec::new(),
additional_edits: item.additional_edits,
commit_characters: Vec::new(),
command: None,
data: None,
@@ -314,7 +322,7 @@ pub async fn lsp_code_actions(
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(_, a)| plugin_code_action(a))
.map(|(plugin, a)| plugin_code_action(&plugin, 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}."))))
@@ -330,24 +338,53 @@ pub async fn lsp_code_actions(
prepare_actions(project, doc, version, actions).await
}
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
let files = a
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());
CodeAction {
title: a.title,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason: None,
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
command: None,
data: None,
data,
server: PLUGIN_SERVER,
}
}
/// 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> {
if action.server != PLUGIN_SERVER {
return host.project(window)?.lsp.resolve_code_action(doc, action).await;
}
let Some(r) = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok()) else {
return Ok(action);
};
let (plugins, d) = plugin_providers(host, window, doc, ProviderKind::CodeActions)
.ok_or_else(|| ide_core::IdeError::unavailable("the plugin that offered this action is gone"))?;
let pending = ide_plugin::CodeAction {
title: action.title.clone(),
kind: action.kind.clone(),
preferred: action.is_preferred,
edits: Vec::new(),
data: Some(r.data),
};
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 }))
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
pub async fn lsp_code_action_query(
host: &Host,
@@ -381,7 +418,7 @@ pub async fn lsp_resolve_code_action(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
let action = resolve_action(host, window, doc, action).await?;
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
}
@@ -391,7 +428,7 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
let action = if action.edit.is_none() && action.command.is_none() {
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
let action = resolve_action(host, window, doc, action).await?;
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
} else {
action
@@ -801,6 +838,25 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
title: "Fix".into(),
kind: None,
preferred: false,
edits,
data: data.map(str::to_owned),
};
let lazy = plugin_code_action("acme.x", 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"));
// 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")));
assert!(eager.edit.is_some() && eager.data.is_none());
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
+15
View File
@@ -292,6 +292,21 @@ impl PluginHost {
.collect()
}
/// The edits of an action `plugin_id` returned with `data`, now that the
/// user picked it: a call with the command budget, not the edit path's.
pub fn resolve_code_action(
&self,
plugin_id: &str,
doc: &ProviderDocument,
action: CodeAction,
) -> ide_core::IdeResult<CodeAction> {
let d = Arc::new(doc.clone());
let timeout = self.limits.timeout;
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
rt.resolve_code_action(&d, &action, timeout)
})
}
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| {
+54 -1
View File
@@ -635,7 +635,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
assert_eq!(f.host.instances(), 0);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
assert_eq!(f.command_titles("misbehave").len(), 8, "contributions stay");
assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay");
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
@@ -2410,3 +2410,56 @@ fn uninstalling_deletes_the_plugins_secrets() {
f.host.uninstall("openidea.ai-completion").unwrap();
assert!(keychain.0.lock().is_empty(), "{:?}", keychain.0.lock().keys().collect::<Vec<_>>());
}
/// Plugin API 1.0 leaves room to grow: event kinds are names, and the editor
/// providers carry snippets, imports, lazily computed quick fixes and tagged
/// problems.
#[test]
fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
let f = fixture(Limits::default());
let pkg = f._tmp.path().join("misbehave-providers");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"),
)
.unwrap();
f.install(&pkg);
let doc = |text: &str| ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: text.into(),
};
let at = ide_core::Pos::new(0, 0);
let items = f.host.provide_completion(&doc("rich"), at, None);
let item = &items[0].1;
assert_eq!(
(item.insert_text.as_str(), item.snippet, item.filter_text.as_deref()),
("f(${1:x})$0", true, Some("fun"))
);
assert_eq!(item.additional_edits[0].new_text, "use f;\n");
// Offered without edits; computed when picked.
let actions = f.host.provide_code_actions(&doc("lazy"), ide_core::Range::default());
let (plugin, action) = actions.into_iter().next().expect("offered");
assert!(action.edits.is_empty() && action.data.as_deref() == Some("fix"));
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone()).unwrap();
assert_eq!(resolved.edits[0].1[0].new_text, "fixed ");
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
// 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);
let err = f.host.run_command("misbehave", "subscribe", Some("file-saved"), f.inv()).unwrap_err();
assert!(err.message.contains("events"), "known, but not granted: {}", err.message);
// Tags the IDE knows, the tool that found it.
let path = f.project.join("a.txt");
f.host.run_command("misbehave", "diagnose", Some(&path.to_string_lossy()), f.inv()).unwrap();
let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap();
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
}
+96 -35
View File
@@ -467,7 +467,6 @@ const MAX_SYNTAX_ITEMS: usize = 200_000;
const MAX_BATCH: usize = 1000;
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
use crate::types::ActionSource as S;
match e {
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
@@ -477,14 +476,7 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
action: a.action,
title: a.title,
source: match a.source {
S::Keyboard => wit_events::ActionSource::Keyboard,
S::Menu => wit_events::ActionSource::Menu,
S::Toolbar => wit_events::ActionSource::Toolbar,
S::ContextMenu => wit_events::ActionSource::ContextMenu,
S::Search => wit_events::ActionSource::Search,
S::Other => wit_events::ActionSource::Other,
},
source: a.source.name().to_owned(),
shortcut: a.shortcut,
}),
}
@@ -670,9 +662,17 @@ impl Runtime {
detail: i.detail.map(|d| clip(d, MAX_TITLE)),
kind: i.kind.map(|k| clip(k, 32)),
insert_text: clip(i.insert_text, 64 << 10),
snippet: i.snippet,
replace: i.replace.map(range_from_wit),
sort_text: i.sort_text.map(|t| clip(t, 64)),
filter_text: i.filter_text.map(|t| clip(t, MAX_TITLE)),
documentation: i.documentation.map(|d| clip(d, 16 << 10)),
additional_edits: i
.additional_edits
.into_iter()
.take(MAX_ADDITIONAL_EDITS)
.map(edit_from_wit)
.collect(),
})
.collect())
}
@@ -699,27 +699,20 @@ impl Runtime {
) -> 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(|a| 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| {
let edits = f
.edits
.into_iter()
.map(|e| ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text })
.collect();
(std::path::PathBuf::from(f.path), edits)
})
.collect(),
})
.collect())
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
}
/// The edits of an action `provide_code_actions` returned with `data`.
pub fn resolve_code_action(
&mut self,
doc: &ProviderDocument,
action: &CodeAction,
timeout: Duration,
) -> Result<CodeAction, CallError> {
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))?;
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
}
pub fn provide_definition(
@@ -968,6 +961,47 @@ const MAX_VIEW_ITEMS: usize = 20_000;
const MAX_CELL: usize = 64 << 10;
const MAX_HTML: usize = 4 << 20;
/// Most additional edits of a completion item.
const MAX_ADDITIONAL_EDITS: usize = 100;
fn edit_from_wit(e: wit_types::TextEdit) -> ide_core::TextEdit {
ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text }
}
fn code_action_from_wit(a: wit_prov::CodeAction) -> 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(),
data: a.data.map(|d| clip(d, 64 << 10)),
}
}
fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
wit_prov::CodeAction {
title: a.title.clone(),
kind: a.kind.clone(),
preferred: a.preferred,
edits: a
.edits
.iter()
.map(|(path, edits)| wit_prov::FileEdit {
path: path.to_string_lossy().into_owned(),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
.collect(),
})
.collect(),
data: a.data.clone(),
}
}
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
s.map(|s| clip(s, max))
}
@@ -975,6 +1009,9 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
fn view_from_wit(v: wit_vt::View) -> View {
let body = match v.body {
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
wit_vt::Body::Other(e) => {
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
}
wit_vt::Body::Tree(items) => ViewBody::Tree(
items
.into_iter()
@@ -1006,10 +1043,8 @@ fn view_from_wit(v: wit_vt::View) -> View {
fields
.into_iter()
.take(MAX_VIEW_ITEMS)
.map(|f| ViewField {
key: clip(f.key, 128),
label: clip(f.label, MAX_TITLE),
value: match f.value {
.filter_map(|f| {
let value = match f.value {
wit_vt::FieldValue::Text(t) => FieldValue::Text(clip(t, MAX_CELL)),
wit_vt::FieldValue::Multiline(t) => FieldValue::Multiline(clip(t, 1 << 20)),
wit_vt::FieldValue::Checkbox(b) => FieldValue::Checkbox(b),
@@ -1017,7 +1052,10 @@ fn view_from_wit(v: wit_vt::View) -> View {
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
selected: clip(selected, MAX_TITLE),
},
},
// A field type of a newer API: left out.
wit_vt::FieldValue::Other(_) => return None,
};
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
})
.collect(),
),
@@ -1067,3 +1105,26 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
ViewEvent::Message { text } => W::Message(text),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ext(kind: &str) -> wit_types::Extension {
wit_types::Extension { kind: kind.into(), data: "{}".into() }
}
#[test]
fn bodies_and_fields_of_a_newer_api_degrade() {
let view = |body| wit_vt::View { toolbar: Vec::new(), body, status: None };
let shown = view_from_wit(view(wit_vt::Body::Other(ext("markdown"))));
assert_eq!(shown.body, ViewBody::Message("This view needs a newer version of OpenIDEA (markdown).".into()));
let field = |key: &str, value| wit_vt::Field { key: key.into(), label: key.into(), value };
let form = view_from_wit(view(wit_vt::Body::Form(vec![
field("color", wit_vt::FieldValue::Other(ext("color"))),
field("name", wit_vt::FieldValue::Text("n".into())),
])));
let ViewBody::Form(fields) = form.body else { panic!("a form") };
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
}
}
+21
View File
@@ -126,6 +126,20 @@ pub enum ActionSource {
Other,
}
impl ActionSource {
/// As plugins see it.
pub fn name(self) -> &'static str {
match self {
Self::Keyboard => "keyboard",
Self::Menu => "menu",
Self::Toolbar => "toolbar",
Self::ContextMenu => "context-menu",
Self::Search => "search",
Self::Other => "other",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ActionPerformed {
@@ -348,9 +362,14 @@ pub struct CompletionItem {
/// WIT `completion-kind` name (`method`, `keyword`, …).
pub kind: Option<String>,
pub insert_text: String,
/// `insert_text` has tab stops and placeholders.
pub snippet: bool,
pub replace: Option<ide_core::Range>,
pub sort_text: Option<String>,
pub filter_text: Option<String>,
pub documentation: Option<String>,
/// Applied with the item elsewhere in the document (an import).
pub additional_edits: Vec<ide_core::TextEdit>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -365,6 +384,8 @@ pub struct CodeAction {
pub kind: Option<String>,
pub preferred: bool,
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
/// The plugin's reference: the edits come from `resolve_code_action`.
pub data: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
+11 -18
View File
@@ -440,20 +440,11 @@ impl vcs::Host for StoreData {
}
}
fn kind_from_wit(k: events::EventKind) -> EventKind {
match k {
events::EventKind::FileOpened => EventKind::FileOpened,
events::EventKind::FileSaved => EventKind::FileSaved,
events::EventKind::EditorFocused => EventKind::EditorFocused,
events::EventKind::ActionPerformed => EventKind::ActionPerformed,
events::EventKind::VcsChanged => EventKind::VcsChanged,
events::EventKind::Scheduled => EventKind::Scheduled,
}
}
impl events::Host for StoreData {
fn subscribe(&mut self, kind: events::EventKind) -> Fallible<()> {
let kind = kind_from_wit(kind);
fn subscribe(&mut self, kind: String) -> Fallible<()> {
let Some(kind) = EventKind::parse(&kind) else {
return Ok(Err(wit::Error::Invalid(format!("no event kind {kind:?} in this OpenIDEA"))));
};
if let Some(cap) = kind.capability()
&& let Err(e) = self.require(cap)
{
@@ -463,8 +454,10 @@ impl events::Host for StoreData {
Ok(Ok(()))
}
fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> {
self.host.subscribe(kind_from_wit(kind), false);
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
if let Some(kind) = EventKind::parse(&kind) {
self.host.subscribe(kind, false);
}
Ok(())
}
}
@@ -542,10 +535,10 @@ impl diagnostics::Host for StoreData {
diagnostics::Severity::Hint => ide_core::Severity::Hint,
},
message: clip(d.message, 4096),
source: Some(source.clone()),
source: Some(d.source.map_or_else(|| source.clone(), |s| clip(s, 128))),
code: d.code.map(|c| clip(c, 128)),
unnecessary: false,
deprecated: false,
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
deprecated: d.tags.iter().any(|t| t == "deprecated"),
server: Some(source.clone()),
})
.collect();
Binary file not shown.
@@ -1 +1 @@
86dfe70a30ab1f7955a16e96c1b32c8be663456b871cc4d3bed7dc6052c062da
c9acbc021f3840649823cb7680e13907d429c6fda8271fdb0ae6e5ea89ed15ee
Binary file not shown.
@@ -1 +1 @@
0dacb82ac35c1f132dc66e85dbf0663b729f818279d3b32cd06403e502fbf880
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
Binary file not shown.
@@ -1 +1 @@
0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
@@ -40,3 +40,11 @@ title = "insert"
[[contributes.commands]]
id = "cancel"
title = "cancel"
[[contributes.commands]]
id = "subscribe"
title = "subscribe"
[[contributes.commands]]
id = "diagnose"
title = "diagnose"
+52 -4
View File
@@ -14,7 +14,9 @@ wit_bindgen::generate!({
generate_all,
});
use exports::openidea::plugin::providers::{self, CodeAction, CompletionItem, Document, InlineSuggestion, LineMarker};
use exports::openidea::plugin::providers::{
self, CodeAction, CompletionItem, Document, FileEdit, InlineSuggestion, LineMarker,
};
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
use exports::openidea::plugin::{commands, event_handler, lifecycle};
use openidea::plugin::{invocation::Invocation, types};
@@ -67,6 +69,23 @@ impl commands::Guest for Misbehave {
}
// The user gave up: not the plugin failing.
"cancel" => Err(types::Error::Cancelled),
// Event kinds are names: one this IDE doesn't know is refused.
"subscribe" => openidea::plugin::events::subscribe(&arg.unwrap_or_default()).map(|()| None),
// Tagged problems from another tool on the line 0 of `arg`.
"diagnose" => {
use openidea::plugin::diagnostics::{self, Diagnostic, Severity};
let start = types::Position { line: 0, character: 0 };
let d = Diagnostic {
range: types::Range { start, end: start },
severity: Severity::Warning,
message: "unused".into(),
code: None,
source: Some("lint".into()),
tags: vec!["unnecessary".into(), "glowing".into()],
related: Vec::new(),
};
diagnostics::publish(&arg.unwrap_or_default(), &[d]).map(|()| None)
}
other => Err(types::Error::NotFound(format!("command {other}"))),
}
}
@@ -113,14 +132,24 @@ impl syntax_provider::Guest for Misbehave {
impl providers::Guest for Misbehave {
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
behave(&doc.text);
// `rich`: a snippet that also adds an import.
let rich = doc.text == "rich";
let start = types::Position { line: 0, character: 0 };
vec![CompletionItem {
label: doc.path.clone(),
detail: None,
kind: Some("keyword".into()),
insert_text: doc.path,
insert_text: if rich { "f(${1:x})$0".into() } else { doc.path },
snippet: rich,
replace: None,
sort_text: None,
filter_text: rich.then(|| "fun".into()),
documentation: None,
additional_edits: if rich {
vec![types::TextEdit { range: types::Range { start, end: start }, text: "use f;\n".into() }]
} else {
Vec::new()
},
}]
}
@@ -128,8 +157,27 @@ impl providers::Guest for Misbehave {
None
}
fn code_actions(_doc: Document, _selection: types::Range) -> Vec<CodeAction> {
Vec::new()
/// `lazy`: one action whose edits come from `resolve-code-action`.
fn code_actions(doc: Document, _selection: types::Range) -> Vec<CodeAction> {
if doc.text != "lazy" {
return Vec::new();
}
vec![CodeAction {
title: "Fix later".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: Vec::new(),
data: Some("fix".into()),
}]
}
fn resolve_code_action(doc: Document, action: CodeAction) -> Result<CodeAction, types::Error> {
if action.data.as_deref() != Some("fix") {
return Err(types::Error::Invalid("not one of mine".into()));
}
let start = types::Position { line: 0, character: 0 };
let edit = types::TextEdit { range: types::Range { start, end: start }, text: "fixed ".into() };
Ok(CodeAction { edits: vec![FileEdit { path: doc.path, edits: vec![edit] }], ..action })
}
fn definition(_doc: Document, _at: types::Position) -> Vec<types::Location> {
+25 -2
View File
@@ -321,6 +321,11 @@ Events are batched: a running plugin gets them within a fraction of a second,
an idle one collects them for 2 s before it is woken, so frequent events
(Key Promoter's actions) don't keep it alive.
Kinds are names (`subscribe` takes a string), so later IDEs add kinds without
a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
### Providers
Declare what the plugin provides in the editor; only plugins whose
@@ -338,7 +343,14 @@ Then `register_plugin!(MyPlugin, providers)` and implement
the document (path, language, version, text). Results join the language
servers' (completion lists, ⌥⏎ quick fixes, ⌘B, gutter icons; a code action
carries edits for any files, applied as one change). A completion item's
`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon.
`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon; it
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
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
result is dropped, and an interruption at 500 ms. Every plugin providing a
kind is asked at the same time, so each has the whole budget.
@@ -393,6 +405,9 @@ collapse of items, form fields, table cells, inserted and deleted rows, and
`html` messages. A tree is a flat list whose items name their `parent`; an
`expandable` item gets its children when it is expanded. A table gets only
the rows it shows (`viewport`): report `total_rows` and return those.
Bodies, field types and view events added by later APIs travel as `Other {
kind, data }`: an IDE that doesn't know a body says it needs updating, and
leaves out a field type it doesn't know.
Showing a view activates the plugin (`on-view:<id>`). `views` become tool
windows; a `customEditors` file (`priority = "default"`) opens in the
@@ -890,7 +905,10 @@ only grows:
Extend with new types and new functions (or interfaces). `types.error` is
used everywhere and is no exception; new situations use `failed(string)`
or `invalid(string)`. Sets that keep growing (completion, token, symbol and
fold kinds, icons) are strings for this reason.
fold kinds, icons, event kinds, action sources, diagnostic tags) are
strings for this reason, and variants that grow (`event`, view `body`,
`view-event`, `field-value`) have an `other(extension)` case, a kind and
JSON data, that carries later cases to older plugins and IDEs.
2. **Host interfaces (imports) may gain functions and interfaces**: older
components don't import them.
3. **Plugin interfaces (exports) never gain functions** once released: the
@@ -902,6 +920,11 @@ only grows:
minor with a message that says to update the IDE or rebuild with the SDK.
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
6. **1.x calls are synchronous and pass documents whole.** Host calls block
the plugin's instance, and providers get the document's text with each
call. WASI 0.3 async components come with 2.0, behind an adapter in the
host that keeps 1.x plugins running; incremental document sync, if
needed, is a new interface.
Checked automatically:
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
aa09a92c0ba9d85a01c56bbf4455e40ae44f005b443ce1002bbe8011408ff975
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
908c2fd177ad2bb9a8a8492505151c3b81579191156c5ec53f705127f50a8ee9
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
+3 -6
View File
@@ -97,12 +97,8 @@ fn diagnostic(p: &Problem, text: &str) -> Diagnostic {
let line_len = text.split('\n').nth(p.line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
let start = p.column.unwrap_or(0).min(line_len);
let end = if p.column.is_some() && start < line_len { start + 1 } else { line_len };
Diagnostic {
range: Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start))),
severity: p.severity,
message: p.message.clone(),
code: Some(p.check.clone()),
}
let range = Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start)));
Diagnostic::new(range, p.severity, p.message.clone()).code(p.check.clone())
}
/// The project containing `path`.
@@ -188,6 +184,7 @@ impl Providers for Checkstyle {
kind: Some("quickfix".into()),
preferred: true,
edits: vec![(doc.path.clone(), vec![edit])],
data: None,
});
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
64270fa0f5b3084c78764f09b8ce161742fdbff9c28fb90590da39594e558db1
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
22876f842f727a2b9aa292986df3393f5b5f30c5c34319f5665f7d2701c8accf
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
14c60782eca79a25cf2c38d58c05be0eb97c4afc607d4cc83302d1445d094aab
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
+3 -6
View File
@@ -52,12 +52,9 @@ pub fn diagnostics(text: &str, reports: &[Report]) -> Vec<Diagnostic> {
.map(|r| {
let line = r.line.map_or(0, |l| l.saturating_sub(1)).min(lines.len().saturating_sub(1) as u32);
let len = lines.get(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
Diagnostic {
range: Range::new(Position::new(line, 0), Position::new(line, len)),
severity: if r.error { Severity::Error } else { Severity::Warning },
message: r.message.clone(),
code: Some("graphviz".into()),
}
let range = Range::new(Position::new(line, 0), Position::new(line, len));
let severity = if r.error { Severity::Error } else { Severity::Warning };
Diagnostic::new(range, severity, r.message.clone()).code("graphviz")
})
.collect()
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7f5aacde2b8a8bcee5365c8cbda91045195688899c6537dc2159037cca74a672
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
45a7ee993b5c234b0d25e5b05ef60e714fa3edf63d6bd0d6a29b13587c8d09ce
63f1bbdf6eca5a4642cd3453ae384ff3f876de0373c12008b757a63852164b71
+3 -6
View File
@@ -129,12 +129,8 @@ pub fn header_edit(path: &str, text: &str, year: i64, owner: &str, license: &str
fn missing(text: &str) -> Diagnostic {
let line = insert_line(text);
let len = text.lines().nth(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
Diagnostic {
range: Range::new(Position::new(line, 0), Position::new(line, len)),
severity: Severity::Info,
message: "No license header".into(),
code: Some(CODE.into()),
}
Diagnostic::new(Range::new(Position::new(line, 0), Position::new(line, len)), Severity::Info, "No license header")
.code(CODE)
}
/// Reports `path` when it takes a header and has none, clears it otherwise.
@@ -274,6 +270,7 @@ impl Providers for LicenseHeader {
kind: Some("quickfix".into()),
preferred: true,
edits: vec![(doc.path.clone(), vec![edit])],
data: None,
}]
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d5cf81455ae8b2b0fd6d1402d8775913bd7999db62b9bdf87675890ce52b1e80
0db5cb24c358651d0e728ab3efb52286290c1a38ec379435225c935d4165ad6a
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
227c8150c6ad7a5a20236764f149c876cbbc3b082763bf2e7fc05de99726b740
257478b050ea360c55e904f2a89c6069293139ff0d97ba860de3f29dac927787
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
bbadcaa7a86a3e0a3f8be6d86aed2229d5cdfd128fda3435b4d64c7e19ad1df2
21e7510d6a3712bf0f21d53a5c8d652e3c2787d89a1fc25998cde96efe124207
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
09cc92e8e731f8f59f3857a71f9a30cc3d9e04a1c32092920f573b0f26592855
04844bfbfbcb1770f4f4742658944c592922b037b2387e9340de58ef0d9baacc
+23 -24
View File
@@ -152,24 +152,12 @@ mod imp {
wit::vcs::fetch(p).map_err(error)
}
fn kind(k: EventKind) -> wit::events::EventKind {
use wit::events::EventKind as K;
match k {
EventKind::FileOpened => K::FileOpened,
EventKind::FileSaved => K::FileSaved,
EventKind::EditorFocused => K::EditorFocused,
EventKind::ActionPerformed => K::ActionPerformed,
EventKind::VcsChanged => K::VcsChanged,
EventKind::Scheduled => K::Scheduled,
}
}
pub fn subscribe(k: EventKind) -> Result<(), Error> {
wit::events::subscribe(kind(k)).map_err(error)
wit::events::subscribe(k.name()).map_err(error)
}
pub fn unsubscribe(k: EventKind) {
wit::events::unsubscribe(kind(k))
wit::events::unsubscribe(k.name())
}
pub fn begin_task(title: &str, cancellable: bool) -> TaskRes {
@@ -189,7 +177,7 @@ mod imp {
}
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
use wit::diagnostics::{Diagnostic as D, Severity as S};
use wit::diagnostics::{Diagnostic as D, RelatedInformation as R, Severity as S};
let items: Vec<D> = items
.iter()
.map(|d| D {
@@ -202,6 +190,23 @@ mod imp {
},
message: d.message.clone(),
code: d.code.clone(),
source: d.source.clone(),
tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")]
.into_iter()
.filter(|(on, _)| *on)
.map(|(_, tag)| tag.to_owned())
.collect(),
related: d
.related
.iter()
.map(|r| R {
location: wit::types::Location {
path: r.location.path.clone(),
range: range_out(r.location.range),
},
message: r.message.clone(),
})
.collect(),
})
.collect();
wit::diagnostics::publish(path, &items).map_err(error)
@@ -264,7 +269,7 @@ mod imp {
}
pub fn event(e: wit::events::Event) -> Event {
use wit::events::{ActionSource as S, Event as E};
use wit::events::Event as E;
match e {
E::FileOpened(p) => Event::FileOpened(p),
E::FileSaved(p) => Event::FileSaved(p),
@@ -274,16 +279,10 @@ mod imp {
E::ActionPerformed(a) => Event::ActionPerformed(ActionPerformed {
action: a.action,
title: a.title,
source: match a.source {
S::Keyboard => ActionSource::Keyboard,
S::Menu => ActionSource::Menu,
S::Toolbar => ActionSource::Toolbar,
S::ContextMenu => ActionSource::ContextMenu,
S::Search => ActionSource::Search,
S::Other => ActionSource::Other,
},
source: ActionSource::parse(&a.source),
shortcut: a.shortcut,
}),
E::Other(x) => Event::Other { kind: x.kind, data: x.data },
}
}
}
+66 -19
View File
@@ -255,6 +255,16 @@ macro_rules! __providers_exports {
$crate::__rt::code_actions::<$plugin>(doc, selection)
}
fn resolve_code_action(
doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document,
action: $crate::providers_bindings::exports::openidea::plugin::providers::CodeAction,
) -> ::core::result::Result<
$crate::providers_bindings::exports::openidea::plugin::providers::CodeAction,
$crate::bindings::openidea::plugin::types::Error,
> {
$crate::__rt::resolve_code_action::<$plugin>(doc, action)
}
fn definition(
doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document,
at: $crate::bindings::openidea::plugin::types::Position,
@@ -420,9 +430,12 @@ pub mod __rt {
detail: i.detail,
kind: i.kind.map(|k| k.as_str().to_owned()),
insert_text: i.insert_text,
snippet: i.snippet,
replace: i.replace.map(crate::host::__range_to),
sort_text: i.sort_text,
filter_text: i.filter_text,
documentation: i.documentation,
additional_edits: i.additional_edits.into_iter().map(edit_out).collect(),
})
.collect()
}
@@ -443,25 +456,54 @@ pub mod __rt {
sel: b::types::Range,
) -> Vec<w::CodeAction> {
let d = doc(d);
with::<T, _>(|x| x.code_actions(&d, crate::host::__range_from(sel)))
.into_iter()
.map(|a| w::CodeAction {
title: a.title,
kind: a.kind,
preferred: a.preferred,
edits: a
.edits
.into_iter()
.map(|(path, edits)| w::FileEdit {
path,
edits: edits
.into_iter()
.map(|e| b::types::TextEdit { range: crate::host::__range_to(e.range), text: e.text })
.collect(),
})
.collect(),
})
.collect()
with::<T, _>(|x| x.code_actions(&d, crate::host::__range_from(sel))).into_iter().map(action_out).collect()
}
pub fn resolve_code_action<T: Plugin + p::Providers + Default>(
d: w::Document,
action: w::CodeAction,
) -> Result<w::CodeAction, b::types::Error> {
let d = doc(d);
with::<T, _>(|x| x.resolve_code_action(&d, action_in(action))).map(action_out).map_err(error_out)
}
fn edit_out(e: crate::TextEdit) -> b::types::TextEdit {
b::types::TextEdit { range: crate::host::__range_to(e.range), text: e.text }
}
fn action_out(a: p::CodeAction) -> w::CodeAction {
w::CodeAction {
title: a.title,
kind: a.kind,
preferred: a.preferred,
edits: a
.edits
.into_iter()
.map(|(path, edits)| w::FileEdit { path, edits: edits.into_iter().map(edit_out).collect() })
.collect(),
data: a.data,
}
}
fn action_in(a: w::CodeAction) -> p::CodeAction {
p::CodeAction {
title: a.title,
kind: a.kind,
preferred: a.preferred,
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(),
data: a.data,
}
}
pub fn definition<T: Plugin + p::Providers + Default>(
@@ -531,6 +573,9 @@ pub mod __rt {
v::FieldValue::Multiline(t) => vt::FieldValue::Multiline(t),
v::FieldValue::Checkbox(c) => vt::FieldValue::Checkbox(c),
v::FieldValue::Choice(o, s) => vt::FieldValue::Choice((o, s)),
v::FieldValue::Other { kind, data } => {
vt::FieldValue::Other(b::types::Extension { kind, data })
}
},
})
.collect(),
@@ -543,6 +588,7 @@ pub mod __rt {
editable: t.editable,
}),
v::Body::Html(h) => vt::Body::Html(h),
v::Body::Other { kind, data } => vt::Body::Other(b::types::Extension { kind, data }),
};
vt::View {
toolbar: view
@@ -582,6 +628,7 @@ pub mod __rt {
vt::ViewEvent::InsertRow(r) => v::ViewEvent::InsertRow(r),
vt::ViewEvent::DeleteRows(r) => v::ViewEvent::DeleteRows(r),
vt::ViewEvent::Message(m) => v::ViewEvent::Message(m),
vt::ViewEvent::Other(x) => v::ViewEvent::Other { kind: x.kind, data: x.data },
};
with::<T, _>(|p| p.handle(&view, event, &ctx)).map_err(error_out)
}
+2 -5
View File
@@ -722,11 +722,8 @@ mod tests {
let out = mock.invocation().project().unwrap().run_process("java", &["-version".into()], None).unwrap();
assert_eq!(out.stdout, "-version");
assert_eq!(mock.process_runs()[0].cwd.as_deref(), Some("/project"));
host::publish_diagnostics(
"/project/a.java",
&[Diagnostic { range: Range::default(), severity: Severity::Warning, message: "m".into(), code: None }],
)
.unwrap();
host::publish_diagnostics("/project/a.java", &[Diagnostic::new(Range::default(), Severity::Warning, "m")])
.unwrap();
assert_eq!(mock.diagnostics()["/project/a.java"].len(), 1);
}
}
+18
View File
@@ -92,12 +92,18 @@ pub struct CompletionItem {
pub label: String,
pub detail: Option<String>,
pub kind: Option<CompletionKind>,
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
pub insert_text: String,
pub snippet: bool,
/// Replaced on accept; `None`: the word at the caret.
pub replace: Option<Range>,
pub sort_text: Option<String>,
/// Matched against what was typed instead of `label`.
pub filter_text: Option<String>,
/// Markdown.
pub documentation: Option<String>,
/// Applied with the item elsewhere in the document (an import).
pub additional_edits: Vec<TextEdit>,
}
impl CompletionItem {
@@ -107,9 +113,12 @@ impl CompletionItem {
detail: None,
kind: Some(kind),
insert_text: label.into(),
snippet: false,
replace: None,
sort_text: None,
filter_text: None,
documentation: None,
additional_edits: Vec::new(),
}
}
}
@@ -129,6 +138,9 @@ pub struct CodeAction {
pub preferred: bool,
/// (absolute path, edits) per file.
pub edits: Vec<(String, Vec<TextEdit>)>,
/// The plugin's own reference: with it and no `edits`, the edits come
/// from [`Providers::resolve_code_action`] once the user picks the action.
pub data: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -157,6 +169,12 @@ pub trait Providers {
Vec::new()
}
/// The edits of an action [`Providers::code_actions`] returned with
/// `data`, when the user picks it (the command budget applies, not 50 ms).
fn resolve_code_action(&mut self, _doc: &Document, action: CodeAction) -> Result<CodeAction, crate::Error> {
Ok(action)
}
fn definition(&mut self, _doc: &Document, _at: Position) -> Vec<Location> {
Vec::new()
}
+87 -1
View File
@@ -143,7 +143,9 @@ pub struct StatusItem {
pub command: Option<String>,
}
/// Kinds of workbench events; later SDKs add more.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum EventKind {
FileOpened,
FileSaved,
@@ -154,17 +156,46 @@ pub enum EventKind {
Scheduled,
}
/// How the user started an action.
impl EventKind {
/// The name the IDE knows it by.
pub fn name(self) -> &'static str {
match self {
Self::FileOpened => "file-opened",
Self::FileSaved => "file-saved",
Self::EditorFocused => "editor-focused",
Self::ActionPerformed => "action-performed",
Self::VcsChanged => "vcs-changed",
Self::Scheduled => "scheduled",
}
}
}
/// How the user started an action; later IDEs may report more.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ActionSource {
Keyboard,
Menu,
Toolbar,
ContextMenu,
Search,
/// Anything else, including sources this SDK doesn't know.
Other,
}
impl ActionSource {
pub fn parse(name: &str) -> Self {
match name {
"keyboard" => Self::Keyboard,
"menu" => Self::Menu,
"toolbar" => Self::Toolbar,
"context-menu" => Self::ContextMenu,
"search" => Self::Search,
_ => Self::Other,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ActionPerformed {
/// Action id (`editor.duplicate-line`).
@@ -177,6 +208,7 @@ pub struct ActionPerformed {
/// A workbench event (see [`crate::host::subscribe`]).
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Event {
FileOpened(String),
FileSaved(String),
@@ -186,6 +218,11 @@ pub enum Event {
VcsChanged(String),
/// The `on-schedule` period elapsed.
Scheduled,
/// A kind this SDK doesn't know: its name and JSON data.
Other {
kind: String,
data: String,
},
}
/// The repository containing a project.
@@ -227,6 +264,41 @@ pub struct Diagnostic {
pub message: String,
/// Rule or check id.
pub code: Option<String>,
/// The tool that found it, when not the plugin itself.
pub source: Option<String>,
/// Code that does nothing (shown faded).
pub unnecessary: bool,
/// Use of something deprecated (struck through).
pub deprecated: bool,
/// Other places that explain it.
pub related: Vec<RelatedInformation>,
}
impl Diagnostic {
pub fn new(range: Range, severity: Severity, message: impl Into<String>) -> Self {
Self {
range,
severity,
message: message.into(),
code: None,
source: None,
unnecessary: false,
deprecated: false,
related: Vec::new(),
}
}
pub fn code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
}
/// Another place that explains a diagnostic.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RelatedInformation {
pub location: Location,
pub message: String,
}
/// An HTTP request (see [`crate::host::fetch`]).
@@ -276,3 +348,17 @@ pub struct ProcessOutput {
pub stdout: String,
pub stderr: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_the_ide_uses() {
assert_eq!(EventKind::ActionPerformed.name(), "action-performed");
assert_eq!(ActionSource::parse("context-menu"), ActionSource::ContextMenu);
assert_eq!(ActionSource::parse("pen"), ActionSource::Other, "a source of a newer IDE");
let d = Diagnostic::new(Range::default(), Severity::Hint, "m").code("c");
assert_eq!((d.code.as_deref(), d.unnecessary, d.related.len()), (Some("c"), false, 0));
}
}
+20
View File
@@ -110,12 +110,19 @@ impl ListItem {
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FieldValue {
Text(String),
Multiline(String),
Checkbox(bool),
/// Options and the chosen one.
Choice(Vec<String>, String),
/// A field type of a newer plugin API: its kind and JSON data (IDEs that
/// don't know it leave the field out).
Other {
kind: String,
data: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -142,6 +149,7 @@ pub struct Table {
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Body {
Message(String),
Tree(Vec<TreeItem>),
@@ -151,6 +159,12 @@ pub enum Body {
/// Shown in a sandboxed frame; its script talks to the plugin with
/// `openidea.postMessage(text)` and `openidea.onMessage(fn)`.
Html(String),
/// A body of a newer plugin API: its kind and JSON data (IDEs that don't
/// know it say they need updating).
Other {
kind: String,
data: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -197,6 +211,7 @@ impl Viewport {
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ViewEvent {
Button(String),
Activate(String),
@@ -213,6 +228,11 @@ pub enum ViewEvent {
InsertRow(u32),
DeleteRows(Vec<u32>),
Message(String),
/// An event this SDK doesn't know: its kind and JSON data.
Other {
kind: String,
data: String,
},
}
/// Content of the plugin's declared views and custom editors.
+13 -27
View File
@@ -2,36 +2,15 @@ package openidea:plugin@1.0.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
use types.{error};
enum event-kind {
file-opened,
file-saved,
/// The focused editor changed.
editor-focused,
/// An IDE action ran. Requires `events.input`.
action-performed,
/// A repository's state changed (commit, checkout, fetch, …).
vcs-changed,
/// The plugin's `on-schedule` period elapsed.
scheduled,
}
/// How the user started an action.
enum action-source {
keyboard,
menu,
toolbar,
context-menu,
search,
other,
}
use types.{error, extension};
record action-performed {
/// Action id (`editor.duplicate-line`).
action: string,
title: string,
source: action-source,
/// How the user started it: `keyboard`, `menu`, `toolbar`,
/// `context-menu`, `search` or `other` (more may be added).
source: string,
/// Its keyboard shortcut, when it has one (`Ctrl+D`).
shortcut: option<string>,
}
@@ -43,14 +22,21 @@ interface events {
action-performed(action-performed),
vcs-changed(string),
scheduled,
/// A kind added after 1.0 (subscribed to by its name).
other(extension),
}
/// Delivers `kind` events from now on, for the rest of the session (also
/// across idle reclaim: an event activates the plugin again). Declare
/// `activation = ["on-event:<kind>"]` to get them before first use.
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
/// changed), `vcs-changed` (a repository's state changed: commit,
/// checkout, fetch…), `action-performed` (an IDE action ran) and
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
/// and an IDE that doesn't know one fails with `invalid`.
/// Requires `events` (`events.input` for `action-performed`).
subscribe: func(kind: event-kind) -> result<_, error>;
unsubscribe: func(kind: event-kind);
subscribe: func(kind: string) -> result<_, error>;
unsubscribe: func(kind: string);
}
/// Long-running work with a progress indicator. While a task is open, the
+12 -1
View File
@@ -2,7 +2,7 @@ package openidea:plugin@1.0.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
use types.{error, range};
use types.{error, range, location};
enum severity {
error,
@@ -11,12 +11,23 @@ interface diagnostics {
hint,
}
/// Another place that explains a diagnostic (the earlier declaration, say).
record related-information {
location: location,
message: string,
}
record diagnostic {
range: range,
severity: severity,
message: string,
/// Rule or check id (`LineLength`).
code: option<string>,
/// The tool that found it, when not the plugin itself (`checkstyle`).
source: option<string>,
/// `unnecessary` (shown faded) and `deprecated` (struck through); unknown ones are ignored.
tags: list<string>,
related: list<related-information>,
}
/// Replaces the plugin's diagnostics for the file at `path` (absolute, in
+13 -1
View File
@@ -5,7 +5,7 @@ package openidea:plugin@1.0.0;
/// on the editing path: a result later than ~50 ms is dropped, a call still
/// running at 500 ms is interrupted.
interface providers {
use types.{position, range, text-edit, location};
use types.{error, position, range, text-edit, location};
/// The document a request is about.
record document {
@@ -24,11 +24,17 @@ interface providers {
/// constant, struct, event, operator or type-parameter. Unknown kinds
/// show no icon.
kind: option<string>,
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
insert-text: string,
snippet: bool,
/// Replaced on accept; `none`: the word at the caret.
replace: option<range>,
sort-text: option<string>,
/// Matched against what was typed instead of `label`.
filter-text: option<string>,
documentation: option<string>,
/// Applied with the item elsewhere in the document (an import).
additional-edits: list<text-edit>,
}
record inline-suggestion {
@@ -48,6 +54,9 @@ interface providers {
kind: option<string>,
preferred: bool,
edits: list<file-edit>,
/// The plugin's own reference: with it and no `edits`, the edits are
/// computed by `resolve-code-action` once the user picks the action.
data: option<string>,
}
record line-marker {
@@ -64,6 +73,9 @@ interface providers {
inline-completion: func(doc: document, at: position) -> option<inline-suggestion>;
/// Actions for `selection` (quick fixes for the plugin's own diagnostics).
code-actions: func(doc: document, selection: range) -> list<code-action>;
/// The edits of an action `code-actions` returned with `data`, when the
/// user picks it (the command budget applies, not the editor's 50 ms).
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
definition: func(doc: document, at: position) -> list<location>;
line-markers: func(doc: document) -> list<line-marker>;
}
+9
View File
@@ -52,6 +52,15 @@ interface types {
range: range,
}
/// A case added after 1.0, carried by the `other` case of a variant that
/// grows (events, view bodies, view events, form fields): its kind and its
/// data as JSON. Whoever doesn't know the kind ignores it or shows a
/// fallback; the SDKs decode the kinds they know.
record extension {
kind: string,
data: string,
}
/// Passed to `lifecycle.activate`.
record context {
plugin-id: string,
+8
View File
@@ -8,6 +8,8 @@ package openidea:plugin@1.0.0;
/// runs in the WebView, except a `html` body, which is shown in a sandboxed
/// frame that can only exchange messages with the plugin.
interface view-types {
use types.{extension};
record button {
id: string,
label: string,
@@ -43,6 +45,8 @@ interface view-types {
checkbox(bool),
/// The options and the chosen one.
choice(tuple<list<string>, string>),
/// A field type added after 1.0 (an IDE that doesn't know it leaves it out).
other(extension),
}
record field {
@@ -72,6 +76,8 @@ interface view-types {
/// network, no IDE API. Its script talks to the plugin with
/// `openidea.postMessage(text)` / `openidea.onMessage(fn)`.
html(string),
/// A body added after 1.0; an IDE that doesn't know it says it needs updating.
other(extension),
}
record view {
@@ -109,6 +115,8 @@ interface view-types {
delete-rows(list<u32>),
/// From the `html` body's script.
message(string),
/// An event added after 1.0, from a body or field added after 1.0.
other(extension),
}
}
+13 -27
View File
@@ -2,36 +2,15 @@ package openidea:plugin@1.0.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
use types.{error};
enum event-kind {
file-opened,
file-saved,
/// The focused editor changed.
editor-focused,
/// An IDE action ran. Requires `events.input`.
action-performed,
/// A repository's state changed (commit, checkout, fetch, …).
vcs-changed,
/// The plugin's `on-schedule` period elapsed.
scheduled,
}
/// How the user started an action.
enum action-source {
keyboard,
menu,
toolbar,
context-menu,
search,
other,
}
use types.{error, extension};
record action-performed {
/// Action id (`editor.duplicate-line`).
action: string,
title: string,
source: action-source,
/// How the user started it: `keyboard`, `menu`, `toolbar`,
/// `context-menu`, `search` or `other` (more may be added).
source: string,
/// Its keyboard shortcut, when it has one (`Ctrl+D`).
shortcut: option<string>,
}
@@ -43,14 +22,21 @@ interface events {
action-performed(action-performed),
vcs-changed(string),
scheduled,
/// A kind added after 1.0 (subscribed to by its name).
other(extension),
}
/// Delivers `kind` events from now on, for the rest of the session (also
/// across idle reclaim: an event activates the plugin again). Declare
/// `activation = ["on-event:<kind>"]` to get them before first use.
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
/// changed), `vcs-changed` (a repository's state changed: commit,
/// checkout, fetch…), `action-performed` (an IDE action ran) and
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
/// and an IDE that doesn't know one fails with `invalid`.
/// Requires `events` (`events.input` for `action-performed`).
subscribe: func(kind: event-kind) -> result<_, error>;
unsubscribe: func(kind: event-kind);
subscribe: func(kind: string) -> result<_, error>;
unsubscribe: func(kind: string);
}
/// Long-running work with a progress indicator. While a task is open, the
+12 -1
View File
@@ -2,7 +2,7 @@ package openidea:plugin@1.0.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
use types.{error, range};
use types.{error, range, location};
enum severity {
error,
@@ -11,12 +11,23 @@ interface diagnostics {
hint,
}
/// Another place that explains a diagnostic (the earlier declaration, say).
record related-information {
location: location,
message: string,
}
record diagnostic {
range: range,
severity: severity,
message: string,
/// Rule or check id (`LineLength`).
code: option<string>,
/// The tool that found it, when not the plugin itself (`checkstyle`).
source: option<string>,
/// `unnecessary` (shown faded) and `deprecated` (struck through); unknown ones are ignored.
tags: list<string>,
related: list<related-information>,
}
/// Replaces the plugin's diagnostics for the file at `path` (absolute, in
+13 -1
View File
@@ -5,7 +5,7 @@ package openidea:plugin@1.0.0;
/// on the editing path: a result later than ~50 ms is dropped, a call still
/// running at 500 ms is interrupted.
interface providers {
use types.{position, range, text-edit, location};
use types.{error, position, range, text-edit, location};
/// The document a request is about.
record document {
@@ -24,11 +24,17 @@ interface providers {
/// constant, struct, event, operator or type-parameter. Unknown kinds
/// show no icon.
kind: option<string>,
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
insert-text: string,
snippet: bool,
/// Replaced on accept; `none`: the word at the caret.
replace: option<range>,
sort-text: option<string>,
/// Matched against what was typed instead of `label`.
filter-text: option<string>,
documentation: option<string>,
/// Applied with the item elsewhere in the document (an import).
additional-edits: list<text-edit>,
}
record inline-suggestion {
@@ -48,6 +54,9 @@ interface providers {
kind: option<string>,
preferred: bool,
edits: list<file-edit>,
/// The plugin's own reference: with it and no `edits`, the edits are
/// computed by `resolve-code-action` once the user picks the action.
data: option<string>,
}
record line-marker {
@@ -64,6 +73,9 @@ interface providers {
inline-completion: func(doc: document, at: position) -> option<inline-suggestion>;
/// Actions for `selection` (quick fixes for the plugin's own diagnostics).
code-actions: func(doc: document, selection: range) -> list<code-action>;
/// The edits of an action `code-actions` returned with `data`, when the
/// user picks it (the command budget applies, not the editor's 50 ms).
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
definition: func(doc: document, at: position) -> list<location>;
line-markers: func(doc: document) -> list<line-marker>;
}
+9
View File
@@ -52,6 +52,15 @@ interface types {
range: range,
}
/// A case added after 1.0, carried by the `other` case of a variant that
/// grows (events, view bodies, view events, form fields): its kind and its
/// data as JSON. Whoever doesn't know the kind ignores it or shows a
/// fallback; the SDKs decode the kinds they know.
record extension {
kind: string,
data: string,
}
/// Passed to `lifecycle.activate`.
record context {
plugin-id: string,
+8
View File
@@ -8,6 +8,8 @@ package openidea:plugin@1.0.0;
/// runs in the WebView, except a `html` body, which is shown in a sandboxed
/// frame that can only exchange messages with the plugin.
interface view-types {
use types.{extension};
record button {
id: string,
label: string,
@@ -43,6 +45,8 @@ interface view-types {
checkbox(bool),
/// The options and the chosen one.
choice(tuple<list<string>, string>),
/// A field type added after 1.0 (an IDE that doesn't know it leaves it out).
other(extension),
}
record field {
@@ -72,6 +76,8 @@ interface view-types {
/// network, no IDE API. Its script talks to the plugin with
/// `openidea.postMessage(text)` / `openidea.onMessage(fn)`.
html(string),
/// A body added after 1.0; an IDE that doesn't know it says it needs updating.
other(extension),
}
record view {
@@ -109,6 +115,8 @@ interface view-types {
delete-rows(list<u32>),
/// From the `html` body's script.
message(string),
/// An event added after 1.0, from a body or field added after 1.0.
other(extension),
}
}