Compare commits

...
Author SHA1 Message Date
Arnaud Ritti 1446d5592c feat(plugins): refresh diagnostics while typing (#2048)
Closes #1952

API 1.3 adds a diagnostics provider for unsaved documents: the host sends versioned text for the declared languages after a 300 ms debounce, cancels superseded calls, drops late or stale answers under the editor-provider budget, and clears results with the document, project or plugin. Ranges are UTF-16. Actor panics are contained, queued work is rejected after a host panic, and diagnostics arriving after plugin cleanup are discarded. Verified on macOS with the diagnostics fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 04:14:27 +08:00
Deng Liuanddengliu fbf57bed23 feat(plugins): generic streaming log editor tab for plugin web views (#2043)
A plugin web view can open a read-only editor tab that streams a process's output, with plugin-defined toggles, Find, wrap, zoom and Clear. The process is authorised in the backend against the exact granted `process:<program>` and its pinned executable, output is bounded, and the stream survives tab switches but ends when the tab closes, the plugin is disabled or the window closes. Adds a web-log test fixture and docs. Verified on macOS; Windows/Linux not run.

Co-authored-by: dengliu <[email protected]>
2026-11-08 04:06:04 +08:00
l0ng-ai 292ddc4d2a fix(lsp): skip widening for indexed source files (#2052)
Fixes #2034

Call hierarchy, Find Usages and rename widen a server's answer by opening project files that mention the symbol's name and asking again. For JDT LS those files are mostly sources it already analyses, so nearly every incoming-calls request ran a second, slow round before anything was shown. Widening now asks the server which candidates are already in its analysis scope (optional `openidea.workspace.coveredFiles` command, served by the bundled JDT extension using the live source search scope) and only opens the rest; a missing or failing answer keeps the previous behaviour. Covers call hierarchy, references and rename. Hierarchy on a multi-module Maven fixture: median 703 ms → 224 ms, one query round.
2026-11-08 03:53:23 +08:00
Deng Liuanddengliu af19880390 feat(plugins): PTY session bridge for plugin web views (#2038)
A plugin web view can open interactive PTY sessions (spawn, input, resize, kill, streamed output and exit). Spawning requires the exact granted `process:<program>` and its pinned executable, with no PATH re-resolution or fallback shell; the cwd is the trusted project, arguments, environment (append-only) and sizes are validated, and at most eight sessions run per plugin. A frame can only drive the sessions it opened; output is acknowledged chunk by chunk and input/output are bounded. Sessions end on permission change, reload, disable, uninstall, frame teardown or window close. Requires API ^1.3; adds a web-pty test fixture. Verified on macOS; Windows/Linux not run.

Co-authored-by: dengliu <[email protected]>
2026-11-08 03:29:36 +08:00
Vadim LazukandInf1mum 30a0521d0d feat(java): navigate ${...} placeholders to configuration files and Spring XML (#2010)
Closes #1658

Go to Declaration on a `${key}` placeholder inside YAML, properties or Spring XML now resolves the key to its definitions in the module's configuration files (profiles included, several targets open a chooser) and in Spring XML. Placeholders are parsed brace-balanced, so nested placeholders and defaults resolve at every position; configuration files come from the same module roots as the Java-side lookup; the file list is cached per root and refreshed on watched-file create/delete/rename. Ranges are UTF-16. Verified on macOS with a Spring Boot project.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 03:19:58 +08:00
Arnaud Ritti 4a78310f76 feat(plugins): support detail panes for tree and list views (#2033)
Closes #1950

Plugins can declare `detail = true` on a tree or list view (any tool-window side, grouped or not) to show the selected item's detail-provider tabs beside the list. The pane follows selection and refresh, clears stale content when the item, project or remote host changes, shows an empty state when nothing is selected, and remembers its resizable divider. Services keeps its single split. Released WIT and IPC shapes are unchanged. Verified on macOS in light and dark themes.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 03:18:44 +08:00
Arnaud Ritti 7cedf0b9be feat(plugins): expose indexed workspace symbol search (#2040)
Closes #1944

API 1.3 lets a plugin search the IDE's workspace symbol index. Results are limited to paths the plugin may read under its `project.read` grant (external content roots and symlink aliases excluded), the search honours the call's cancellation and deadline including lock waits, and ranges use UTF-16. Incremental index updates no longer publish symlink-ancestor aliases. Verified on macOS with the test fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 03:15:08 +08:00
Arnaud Ritti 0bee9b7aab feat(plugins): allow custom gutter marker icons (#2044)
Closes #1951

Gutter markers only recognised a short alias list and the runtime truncated icon references at 32 bytes, so ordinary icon names and package paths were dropped. Markers now accept any built-in icon name, and API 1.3 `contributes.markerIcons` declares package SVGs (with an optional dark variant) that are size-limited, sanitised and rendered through the icon registry. The shared SVG sanitiser now also rejects URL strings in any CSS function (e.g. image-set), not only url(). Verified on macOS in light and dark themes.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 03:12:01 +08:00
WangTongchaoandchaoes 3efec1c848 feat(plugins): let a plugin keep a process running behind a handle (#2027)
Closes #1826

API 1.3 adds `run-command` (one-shot with stdin, cwd and environment) and a handle-based `start` for a long-running process with incremental stdin/stdout/stderr, both gated by the existing `process:<program>` grants. A plugin's environment entries are append-only: a key the child would already inherit (including the login-shell PATH) is refused with an error naming it, as are empty or malformed keys. Processes run in their own process group and are killed with their descendants when the handle is dropped, the plugin is disabled, unloaded or reclaimed, or the owning window closes; at most eight handles per plugin. Output is bounded and UTF-8 safe, stdin writes honour cancellation, and validation failures return typed errors instead of trapping the plugin. Verified on macOS; Windows/Linux not run.

Co-authored-by: chaoes <[email protected]>
2026-11-08 02:53:04 +08:00
RuyimgByCN e024c13db7 feat(run): Tomcat Server run configuration and application server dialogs (#1963)
Adds a local Tomcat Server run configuration: Server / Deployment / Logs / Startup-Connection tabs, an Application Servers dialog (home, version, base directory, libraries), HTTP/HTTPS/JMX/AJP ports, update and frame-deactivation actions, JRE and VM options, and run/debug with redeploy.

Tomcat HOME and a configured base directory are read-only sources: every launch, build metadata write, sync and update goes to an IDE-owned CATALINA_BASE per configuration, which rejects paths equal to or overlapping either source (symlinks included). With "Deploy applications configured in Tomcat instance" on, the source's webapps and conf/Catalina/<host> descriptors are copied into it; off, the recreated base has none. Controls without a runtime consumer were removed; removing every server profile clears the selection. Verified on macOS (run, port changes, sync/redeploy, servlet breakpoint, source trees unchanged); Windows/Linux not run.

Co-authored-by: RuyimgByCN <[email protected]>
2026-11-08 02:47:33 +08:00
Arnaud Ritti 780e57d0c5 fix(test): complete syntax parsing across language helpers (#2042)
Headless language tests could read an earlier, partial syntax tree after ensureSyntaxTree finished parsing, so highlight and state-based assertions depended on the initial 20 ms parse budget and failed intermittently under load. Shared test helpers now return the completed tree (or publish it to state), and every affected test uses them.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:45:26 +08:00
Muuandmuutot 200ab10e65 refactor(lsp): register per-language semantics under lsp/semantic (#1687)
Python's highlighting hooks, member intentions and Implement Methods, and PHP's construct docs move under semantic/python and semantic/php behind a small registry of language-specific behaviour. Intentions now run only for the current document's language. Behaviour is otherwise unchanged; the generic language sets stay next to their users.

Co-authored-by: muutot <[email protected]>
2026-11-08 02:42:53 +08:00
arookieofc dfcdd8d764 fix(run): drop a moved file's temporary context configuration (#2037)
A single file's context configuration keeps the path it was produced
for, so moving main.cxx out of src/ left 'context:file:src/main.cxx'
selected while the gutter produced a second configuration for the new
path — the run command still compiled the old file.

fsChanged now drops temporary context configurations (context:file: and
the compiled context:build: partner) whose file was removed, the way
IDEA deletes the context configuration with the file. Saved and
detected configurations stay.

Fixes l0ng-ai/openidea#2035
2026-11-08 02:42:42 +08:00
l0ng-ai 27e73ea2cb fix(java): show Lombok generated members in Structure (#2046)
Refs #2026

The Java defaults left JDT LS's generated-symbol filter on, so Lombok getters, setters, constructors, builder methods and logger fields were missing from Structure and File Structure. Enable `java.symbols.includeGeneratedCode`; generated entries now appear and navigate to their field or annotation. Adds a real-JDT LS regression test (manual, ignored by default).
2026-11-08 02:40:20 +08:00
Arnaud Ritti bb99c7c4d1 fix(plugins): collapse the tables fixture field guard (#2041)
The tables test fixture's nested editable-field check tripped clippy::collapsible_if under -D warnings, failing the Plugins CI job. Collapsed it with unchanged behaviour and rebuilt the fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:39:28 +08:00
Arnaud Ritti f6531542de feat(plugins): add fixed filenames and file template variables (#2032)
Closes #1954

Plugin file templates could only take a typed name plus a fixed extension, so dot-files such as .dockerignore and templates needing more than a name weren't expressible. Templates (API ^1.3) can now declare a fixed `fileName` and named `variables` that the New File dialog asks for, and plugin templates get the same Java/Kotlin/Go/PHP package inference as built-in ones. Unsafe filenames and existing files are refused. The dialog no longer submits on Enter while an IME is composing, and its variable form has proper padding. Adds a file-templates test fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:37:26 +08:00
Arnaud Ritti 9e6cb7b93e feat(plugins): allow prompts while resolving code actions (#2025)
Closes #1945

Resolving a lazy code action or file action dropped the invoking window, so a plugin couldn't ask the user anything (input, pick, confirm) while building its edit. Resolution now runs with that window, so prompts work during resolution; previews and Actions on Save never prompt, cancellation keeps the text unchanged, and stale edits are rejected. WIT and SDK docs describe the new behaviour; the released API surface is unchanged.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:26:26 +08:00
jalja27andjalja27 0b88599463 fix(agents): answer Qwen Code's questions in the question card (#1990)
Qwen Code sends AskUserQuestion questions as `_meta.qwenQuestions` and expects `answers` keyed by question index beside its Submit option, so its questions never reached the question card and Submit answered with nothing. The card now reads those questions and replies in that shape; skipped questions cancel the request, and bypassed approvals no longer auto-submit any request that carries questions.

Co-authored-by: jalja27 <[email protected]>
2026-11-08 02:15:59 +08:00
Arnaud Ritti da3d996e10 feat(plugins): group views and apply visibility conditions (#2019)
Closes #1949. Plugin API 1.3: views from several plugins can share one tool window (shown as tabs) and a view can declare a visibility condition (e.g. only when the project contains a file, or for the active file) so it appears only when it applies. Additive within API 1.x, with a fixture plugin, regression tests and docs.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:04:10 +08:00
Shohruh Ismoilovandshohruh-genius 4c6e535560 fix(go): inherit project build tags in detected launches (#1996)
Detected Go run, test and debug configurations ignored the build tags set for the project, so tagged files were left out. They now inherit the project's tags (unless the configuration opts out), including reruns, file-scoped tests and debug launches; maintenance commands keep running without them.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 02:01:37 +08:00
Shohruh Ismoilovandshohruh-genius 705f3557a8 fix(go): use directory output for build-only configurations (#1994)
A build-only Go configuration over several packages (./...) failed because go build -o expects a directory for multiple main packages. Build-only configurations now pass an output directory (paths with spaces handled), so each package's executable is produced; explicit outputs and build-and-run keep their behaviour.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 01:59:50 +08:00
Shohruh Ismoilovandshohruh-genius 4dcf7b0fb2 fix(build): compile all Go workspace modules (#1992)
Build in a go.work project compiled only one module. It now builds every workspace member, including members outside the project folder, with GOWORK pointing at the canonical go.work so external members keep their workspace context.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 01:58:31 +08:00
Shohruh Ismoilovandshohruh-genius a6d24e684f fix(run): preserve Go declaration and subtest scopes (#1988)
Go gutter and context launches keep the exact scope of the declaration or nested subtest they were started from (aliased and multi-line declarations included), so running one nested subtest no longer widens to its siblings.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 01:57:00 +08:00
Vadim LazukandInf1mum 1b889a5147 feat(database): editable result grid for single-table query results (#1854)
Closes #1834. A console result grid is editable in place when its rows trace back to one base table: a plain SELECT of column references / * from one table (schema-qualified or aliased, resolved through the console's active schema) whose primary key columns are all in the result. Edits, added and deleted rows reuse the table editor's workflow and are applied in one transaction, then the query re-runs. Aliased or computed columns stay read-only; joins, aggregates, subqueries/CTEs, unions and ambiguous column provenance show a read-only banner with the reason.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 01:50:18 +08:00
Vadim LazukandInf1mum 4d088c35ff fix(lsp): leave documents open on a server out of its watched-file changes (#1995)
Closes #1780. When a file open in the editor changed on disk, its language server got the change twice: a workspace/didChangeWatchedFiles notification, then the editor's reload as textDocument/didChange diffed against the pre-reload copy. Servers that re-read files on watched changes (csharp-ls) applied both and kept analysing stale content. Change events for documents open on that server instance are now left out (paths compared canonically); create and delete events are still delivered, servers without document sync still get every event, and other servers are unaffected.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 01:43:59 +08:00
Andrea Basileandandreabasile 60d7dcdca0 feat(remote): reopen a local project in its dev container (#1843)
Refs #1083 (step B). A local project with a devcontainer.json can be reopened in its dev container (File › Reopen in Container, Find Action): the Dev Containers CLI brings the container up and the window connects to openidea-server inside it, as for WSL, so terminal, language servers, run, debug and git work in the container. The project's identity stays the local folder, so reopening from Recent Projects finds the same container. Missing CLI gives install guidance; trust of the local project carries over. Pure CLI output parsing lives in ide-remote; a failed connection no longer closes the current backend early, saved locations keep the container configuration, and Docker context precedence follows the CLI.

Co-authored-by: andreabasile <[email protected]>
2026-11-08 01:43:03 +08:00
arookieofcanda-rookie-of-C-language 7479b7ad9d fix(tools): resume partial downloads with curl -C - (#2029)
Fixes #2028. A download that died mid-stream kept its partial file but started over from zero on retry. Downloads now resume with curl -C - when the partial belongs to the same URL (a .url sidecar records it; an untracked file or a different URL starts from zero, so a stale partial can't be spliced in); a server without byte ranges (curl exit 33) or HTTP 416 drops the partial and restarts. Covered by tests against a local HTTP server with and without Range support.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-08 01:20:53 +08:00
Jaw 053a2c37d8 feat(macos): navigate editor history with trackpad swipes (#1934)
Closes #1836. AppKit reports page swipes (three-finger NSEventTypeSwipe and two-finger page swipes) outside the web view's key/mouse events, so the desktop shell now forwards them as SwipeLeft / SwipeRight keymap strokes that can be recorded and remapped. Every macOS preset (IntelliJ, Eclipse, VS Code) binds SwipeRight to Back and SwipeLeft to Forward; two-finger swipes over horizontally scrollable content still scroll, and turning off the system page-swipe gesture disables navigation.

Co-authored-by: jaw52 <[email protected]>
2026-11-08 01:16:31 +08:00
l0ng-ai a40a909db5 feat(search): edit files in the Find in Files preview (#2031)
Closes #2004. The Find in Files preview was a read-only standalone view with its own copy of the file. It now attaches to the shared document the editor tabs use (editor/api attachDocument with shared history): edits show up in open tabs and mark them modified, undo/redo go through the text tab's history, Save works, and match highlights follow the edits. Library sources, archives, large/binary files stay read-only; Enter in the preview inserts a newline instead of opening the result.
2026-11-08 01:13:13 +08:00
Deng Liuanddengliu c1e32b091a feat(plugins): prepare PATH in run_process and raise MAX_VIEW_MESSAGE to 8 MiB (#1942)
Plugin process: programs now start with the same prepared environment as service logs (ide_core::process::prepare), so tools such as kubectl find credential helpers on the login-shell PATH when the IDE was launched from the GUI. View messages may now be up to 8 MiB (was 1 MiB) so explorer views can post large snapshots.

Co-authored-by: dengliu <[email protected]>
2026-11-08 00:50:46 +08:00
Qingandqtvbwfn 72b39d819f fix(run): give each Maven module its own Spring Boot scan budget (#2021)
Spring Boot detection shared one 400-file scan budget across all modules, so a large common library before the application modules used it up and later apps' launch configurations were never detected. Each module applying the Spring Boot plugin now gets its own budget.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-08 00:47:41 +08:00
xtr-hubandxtr-hub b790a9ddb6 feat(run): keep service debugging in the Services tool window (#1844)
Closes #1684. Debug and rerun started from the Services tool window used startSession(), which opened the Debug tool window whenever run.showOnStart was on. Services-originated debug sessions and reruns now start with show: false and stay in the Services detail pane (in-place launches and reruns keep that origin; a breakpoint hit still shows the paused session). Debug from the toolbar, gutter, context menu and the Debug window keeps opening the Debug window. Also: palette themes inherit the macOS window button colours, and the title bar's fullscreen buttons skip Tauri calls outside Tauri (fixes the theme-token and title-bar test failures on main).

Co-authored-by: xtr-hub <[email protected]>
2026-11-08 00:46:39 +08:00
372 changed files with 22415 additions and 1670 deletions

No files matched your search

+52 -6
View File
@@ -1692,12 +1692,17 @@ impl Task {
/// Blocks the calling (blocking-pool) thread until the user answers.
fn ask_permission(self: &Arc<Self>, params: &Value) -> Value {
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
let (questions, by_index) = asked_questions(params);
let title = params
.pointer("/toolCall/title")
.and_then(Value::as_str)
.filter(|t| !t.is_empty())
.unwrap_or("The agent asks for permission")
.to_owned();
// An agent that answers by index titles its request "Ask user N question(s)"; its
// first question says what it wants, as the Claude Code adapter titles its own.
let title =
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
// A subagent's request is answered in the task's conversation, named after the subagent.
let session = params.get("sessionId").and_then(Value::as_str);
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
@@ -1719,10 +1724,6 @@ impl Task {
.collect()
})
.unwrap_or_default();
let questions: Vec<AgentQuestion> = params
.pointer("/toolCall/_meta/questions")
.and_then(|questions| serde_json::from_value(questions.clone()).ok())
.unwrap_or_default();
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
let (tx, rx) = std::sync::mpsc::channel();
let entry =
@@ -1736,7 +1737,7 @@ impl Task {
.as_deref()
.filter(|_| !asked.is_empty())
.and_then(|reply| serde_json::from_str::<serde_json::Map<String, Value>>(reply).ok());
let name = match answers {
let name = match &answers {
Some(answers) => asked
.iter()
.filter_map(|question| answers.get(question))
@@ -1762,6 +1763,31 @@ impl Task {
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
self.set_status(AgentStatus::Running, None);
}
// An agent that answers by index reads the answers beside the outcome, keyed by
// question index, with its Submit option chosen; anything less cancels the request.
if by_index {
let submit = options.iter().find(|option| option.kind == "allow_once").or_else(|| options.first());
return match (answers.as_ref(), submit) {
(Some(answers), Some(submit)) => {
let picked: serde_json::Map<String, Value> = asked
.iter()
.enumerate()
.filter_map(|(index, question)| {
let items = answers.get(question)?;
let answer = match items {
Value::Array(items) => {
items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")
}
item => item.as_str().unwrap_or_default().to_owned(),
};
Some((index.to_string(), json!(answer)))
})
.collect();
json!({ "outcome": { "outcome": "selected", "optionId": submit.id }, "answers": picked })
}
_ => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
match answer {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
@@ -1775,7 +1801,10 @@ impl Task {
}
let mut pending = self.permissions.lock();
for entry in s.conversation.main.entries.iter().rev() {
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
// A request with questions is never satisfied by a bypass: its Submit option
// would answer with no answers.
if let AgentEntry::Permission { id, options, questions, chosen: None, .. } = entry
&& questions.is_empty()
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
&& let Some(reply) = pending.remove(id)
{
@@ -1840,6 +1869,23 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
}
/// The questions a permission request asks, and whether they answer by question index.
/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id;
/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside
/// the id of its Submit option.
fn asked_questions(params: &Value) -> (Vec<AgentQuestion>, bool) {
for (pointer, by_index) in [("/toolCall/_meta/questions", false), ("/toolCall/_meta/qwenQuestions", true)] {
if let Some(questions) = params
.pointer(pointer)
.and_then(|questions| serde_json::from_value::<Vec<AgentQuestion>>(questions.clone()).ok())
.filter(|questions| !questions.is_empty())
{
return (questions, by_index);
}
}
(Vec::new(), false)
}
/// ACP content blocks: the text, each file (embedded when its text is in `contents`, else
/// a `resource_link`), then the images.
fn blocks(prompt: &AgentPrompt, contents: &[Option<String>]) -> Vec<Value> {
+209 -5
View File
@@ -9,6 +9,7 @@ struct FakeAgent {
/// Every connection's agent side, kept open.
servers: Mutex<Vec<Peer>>,
calls: Mutex<Vec<String>>,
permission_replies: Mutex<Vec<Value>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
@@ -191,9 +192,37 @@ impl Handler for FakeAgent {
None,
))
.unwrap();
self.permission_replies.lock().push(answer.clone());
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("answered {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
return Ok(json!({ "stopReason": "end_turn" }));
}
if text == "qwen ask" {
// Qwen Code's ACP dialect: the questions as `qwenQuestions`, Submit and
// Cancel as the options, the answers keyed by question index.
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session,
"toolCall": {"toolCallId": "q2", "title": "Ask user 2 questions", "kind": "think",
"_meta": {"toolName": "ask_user_question", "qwenInteractionKind": "user_question",
"qwenQuestions": [
{"question": "Which database?", "header": "Database", "options": [
{"label": "Postgres", "description": "Relational"},
{"label": "Redis", "description": "Key-value"}]},
{"question": "Which caches?", "header": "Caches", "multiSelect": true,
"options": [{"label": "Disk"}, {"label": "Memory"}]}]}},
"options": [{"optionId": "proceed_once", "name": "Submit", "kind": "allow_once"},
{"optionId": "cancel", "name": "Cancel", "kind": "reject_once"}]}),
None,
))
.unwrap();
let outcome = answer.pointer("/outcome/outcome").and_then(Value::as_str).unwrap_or("none");
let option = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
let answers = answer.get("answers").cloned().unwrap_or(Value::Null);
self.say(&session, &format!("answered {outcome} {option} {answers}"));
return Ok(json!({ "stopReason": "end_turn" }));
}
if text == "wait" {
// Works until cancelled.
@@ -692,9 +721,10 @@ fn prompts_become_acp_content_blocks() {
}
#[tokio::test(flavor = "multi_thread")]
async fn answered_questions_close_the_question_with_every_picked_label() {
async fn claude_questions_keep_the_answer_object_as_the_option_id() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
@@ -712,13 +742,22 @@ async fn answered_questions_close_the_question_with_every_picked_label() {
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
AgentEntry::Permission { id, title, questions, .. } => {
assert_eq!(title, "AskUserQuestion");
assert_eq!(questions.len(), 2);
assert_eq!(questions[0].question, "Which layout?");
assert!(!questions[0].multi_select);
assert_eq!(questions[1].question, "Which checks?");
assert!(questions[1].multi_select);
Some(*id)
}
_ => None,
})
.unwrap();
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
mgr.respond(&task.id, question, Some(reply)).unwrap();
mgr.respond(&task.id, question, Some(reply.clone())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(*agent.permission_replies.lock(), [json!({"outcome": {"outcome": "selected", "optionId": reply}})]);
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
@@ -729,6 +768,113 @@ async fn answered_questions_close_the_question_with_every_picked_label() {
);
}
/// Qwen Code asks its questions as `_meta.qwenQuestions` with Submit/Cancel options; the
/// card answers with `answers` keyed by question index beside the Submit option's id.
#[tokio::test(flavor = "multi_thread")]
async fn qwen_questions_answer_by_index_beside_the_submit_option() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("qwen ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let (id, title, questions) = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, title, questions, .. } => Some((*id, title.clone(), questions.clone())),
_ => None,
})
.unwrap();
assert_eq!(title, "Which database?", "the first question, not the request's own title");
assert_eq!(
questions,
vec![
AgentQuestion {
header: "Database".into(),
question: "Which database?".into(),
multi_select: false,
options: vec![
AgentQuestionOption { label: "Postgres".into(), description: "Relational".into(), preview: None },
AgentQuestionOption { label: "Redis".into(), description: "Key-value".into(), preview: None },
],
},
AgentQuestion {
header: "Caches".into(),
question: "Which caches?".into(),
multi_select: true,
options: vec![
AgentQuestionOption { label: "Disk".into(), description: String::new(), preview: None },
AgentQuestionOption { label: "Memory".into(), description: String::new(), preview: None },
],
},
]
);
let reply = json!({"Which database?": ["Postgres"], "Which caches?": ["Disk", "Memory"]}).to_string();
mgr.respond(&task.id, id, Some(reply)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
let expected = format!("answered selected proceed_once {}", json!({"0": "Postgres", "1": "Disk, Memory"}));
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == &expected)), "{entries:?}");
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Postgres; Disk, Memory")),
"{entries:?}"
);
}
/// A skipped Qwen question cancels the request: qwen-code reads the answers it expects
/// only beside a chosen option, and `reject` is none of the options it offered.
#[tokio::test(flavor = "multi_thread")]
async fn skipped_qwen_questions_cancel_the_request() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("qwen ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let id = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, questions, .. } if !questions.is_empty() => Some(*id),
_ => None,
})
.unwrap();
mgr.respond(&task.id, id, Some("reject".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "answered cancelled none null")),
"{entries:?}"
);
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")),
"{entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let tmp = tempfile::tempdir().unwrap();
@@ -1222,6 +1368,64 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permissions() {
for prompt in ["ask", "qwen ask"] {
for bypass_at_start in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
// Cursor's client-side approval policy applies independently of the question dialect.
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: "cursor".into(),
config: if bypass_at_start {
vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }]
} else {
vec![]
},
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let id = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|entry| match entry {
AgentEntry::Permission { id, questions, chosen: None, .. } if questions.len() == 2 => Some(*id),
_ => None,
})
.unwrap();
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
assert!(mgr.task(&task.id).unwrap().permissions.lock().contains_key(&id), "{prompt}, {bypass_at_start}");
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::NeedsInput);
let reply = if prompt == "ask" {
json!({"Which layout?": ["Grid"], "Which checks?": ["Disk"]})
} else {
json!({"Which database?": ["Postgres"], "Which caches?": ["Memory"]})
};
mgr.respond(&task.id, id, Some(reply.to_string())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("Fix"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|entry| matches!(entry,
AgentEntry::Permission { questions, chosen: Some(name), .. }
if questions.is_empty() && name == "Allow")),
"{entries:?}"
);
assert!(entries.iter().any(|entry| matches!(entry, AgentEntry::Agent { text } if text == "chose yes")));
mgr.shutdown();
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_choice_applies_before_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
+89 -3
View File
@@ -153,7 +153,7 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod"]) {
} else if has(dir, &["go.mod", "go.work"]) {
Some(BuildSystem::Go)
} else if has(dir, &["tsconfig.json", "package.json"]) {
Some(BuildSystem::TypeScript)
@@ -227,7 +227,7 @@ pub fn plan(
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
BuildSystem::Cargo => cargo_steps(&dir, kind),
// Go builds the file's package (a module holds many).
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
};
@@ -374,7 +374,31 @@ fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> V
steps
}
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
fn go_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
if dir.join("go.work").is_file() {
let work_file = dunce::canonicalize(dir.join("go.work"))?;
let modules = ide_core::go::work_modules(&std::fs::read_to_string(dir.join("go.work"))?);
if modules.is_empty() {
return Err(IdeError::invalid("The Go workspace contains no modules"));
}
let mut steps = Vec::new();
for module in modules {
let module = dir.join(module);
if !module.join("go.mod").is_file() {
return Err(IdeError::not_found(format!("Go workspace module has no go.mod: {}", module.display())));
}
let mut module_steps = go_module_steps(&module, kind);
for step in &mut module_steps {
step.env.insert("GOWORK".into(), work_file.to_string_lossy().into_owned());
}
steps.extend(module_steps);
}
return Ok(steps);
}
Ok(go_module_steps(dir, kind))
}
fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
let mut args = vec!["build"];
@@ -813,6 +837,68 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
#[cfg(test)]
mod tests {
#[test]
fn go_workspace_build_commands_compile_members_with_local_dependencies() {
let Some(go) = ide_core::process::find_executable("go", &ide_core::tool_path::search_path()) else {
return;
};
let tmp = tempfile::tempdir().unwrap();
let root = &tmp.path().join("workspace");
write(root, "go.work", "go 1.22\nuse (\n ./app\n ./lib\n ../external\n)\n");
write(root, "lib/go.mod", "module example.com/lib\ngo 1.22\n");
write(root, "lib/lib.go", "package lib\nfunc Value() int { return 1 }\n");
write(root, "app/go.mod", "module example.com/app\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "app/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
write(root, "../external/go.mod", "module example.com/external\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "../external/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
let plan = plan(root, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
for step in plan.steps {
let result = std::process::Command::new(&go)
.args(&step.args)
.envs(&step.env)
.env("GOPROXY", "off")
.current_dir(&step.cwd)
.output()
.unwrap();
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
}
}
#[test]
fn go_workspace_builds_every_use_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
write(&root, "go.work", "go 1.22\nuse (\n ./api\n \"./lib with spaces\"\n ../shared\n)\n");
for module in ["api", "lib with spaces", "../shared"] {
write(&root.join(module), "go.mod", "module example.com/p\ngo 1.22\n");
}
for kind in [BuildKind::Build, BuildKind::Rebuild, BuildKind::Artifacts] {
let p = plan(&root, &BuildModel::default(), kind, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Go);
assert_eq!(p.steps.len(), 3);
for (step, module) in p.steps.iter().zip(["api", "lib with spaces", "../shared"]) {
assert_eq!(step.cwd, root.join(module));
let expected =
if kind == BuildKind::Rebuild { vec!["build", "-a", "./..."] } else { vec!["build", "./..."] };
assert_eq!(step.args, expected);
}
}
let member =
plan(&root, &BuildModel::default(), BuildKind::Build, Some(&root.join("api/pkg/a.go")), None).unwrap();
assert_eq!(member.steps.len(), 1);
assert_eq!(member.steps[0].cwd, root.join("api"));
assert_eq!(member.steps[0].args, ["build", "./pkg/..."]);
write(&root, "go.work", "use ./missing\n");
assert!(
plan(&root, &BuildModel::default(), BuildKind::Build, None, None)
.unwrap_err()
.to_string()
.contains("go.mod")
);
write(&root, "go.work", "go 1.22\n");
assert!(plan(&root, &BuildModel::default(), BuildKind::Build, None, None).is_err());
}
#[test]
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
+124
View File
@@ -0,0 +1,124 @@
//! Go workspace module paths shared by project builds and run detection.
use std::path::PathBuf;
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
pub fn work_modules(text: &str) -> Vec<PathBuf> {
let mut tokens = Vec::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\n' | '(' | ')' => tokens.push(ch.to_string()),
ch if ch.is_whitespace() => {}
'/' if chars.peek() == Some(&'/') => {
for c in chars.by_ref() {
if c == '\n' {
tokens.push("\n".into());
break;
}
}
}
'"' | '`' => {
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
tokens.push(token);
}
_ => {
let mut token = ch.to_string();
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
token.push(chars.next().unwrap());
}
tokens.push(token);
}
}
}
let mut out = Vec::new();
let mut start = true;
let mut tokens = tokens.into_iter();
while let Some(token) = tokens.next() {
if token == "\n" {
start = true;
continue;
}
if start
&& token == "use"
&& let Some(path) = tokens.next()
{
if path == "(" {
let mut paths = Vec::new();
let mut closed = false;
for path in tokens.by_ref() {
if path == ")" {
closed = true;
break;
}
if path != "\n" && !path.is_empty() {
paths.push(PathBuf::from(path));
}
}
if closed {
out.extend(paths);
}
} else if path != "\n" && !path.is_empty() {
out.push(PathBuf::from(path));
}
}
start = false;
}
let mut seen = std::collections::HashSet::new();
out.retain(|path| seen.insert(path.clone()));
out
}
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
let mut bytes = Vec::new();
while let Some(ch) = chars.next() {
if ch == quote {
return String::from_utf8(bytes).ok();
}
if ch == '\\' && quote == '"' {
let escaped = chars.next()?;
let byte = match escaped {
'a' => 7,
'b' => 8,
'f' => 12,
'n' => 10,
'r' => 13,
't' => 9,
'v' => 11,
'\\' => b'\\',
'"' => b'"',
'x' => {
let digits: String = chars.by_ref().take(2).collect();
if digits.len() != 2 {
return None;
}
u8::from_str_radix(&digits, 16).ok()?
}
'u' | 'U' => {
let width = if escaped == 'u' { 4 } else { 8 };
let digits: String = chars.by_ref().take(width).collect();
if digits.len() != width {
return None;
}
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
bytes.extend_from_slice(ch.to_string().as_bytes());
continue;
}
'0'..='7' => {
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
if digits.len() != 3 {
return None;
}
u8::from_str_radix(&digits, 8).ok()?
}
_ => return None,
};
bytes.push(byte);
} else {
if ch == '\n' && quote == '"' {
return None;
}
bytes.extend_from_slice(ch.to_string().as_bytes());
}
}
None
}
+1
View File
@@ -7,6 +7,7 @@
pub mod editor_settings;
pub mod error;
pub mod event;
pub mod go;
pub mod ids;
pub mod ignored;
pub mod inspection_settings;
+49 -7
View File
@@ -443,7 +443,7 @@ pub fn compose_op(
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_HOST`, else the context's endpoint).
/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
@@ -451,17 +451,31 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Default => default_daemon_url(
ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()),
ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()),
|name| context_host(&DockerEndpoint::Default, name),
)?,
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
fn default_daemon_url(
context: Option<&str>,
host: Option<&str>,
lookup: impl FnOnce(Option<&str>) -> IdeResult<String>,
) -> IdeResult<String> {
let context = context.filter(|v| !v.is_empty());
if context.is_some() {
return lookup(context);
}
match host.filter(|v| !v.is_empty()) {
Some(host) => Ok(host.to_owned()),
None => lookup(None),
}
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
@@ -506,6 +520,34 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
mod local_tests {
use super::*;
#[test]
fn the_selected_context_overrides_docker_host() {
let local = "unix:///var/run/docker.sock";
let remote = "ssh://me@build";
for host in [None, Some(""), Some(local)] {
let url = default_daemon_url(Some("build"), host, |name| {
assert_eq!(name, Some("build"));
Ok(remote.into())
})
.unwrap();
assert!(!daemon_url_is_local(&url));
}
for context in [None, Some("")] {
assert_eq!(
default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(),
local
);
assert_eq!(
default_daemon_url(context, None, |name| {
assert_eq!(name, None);
Ok(remote.into())
})
.unwrap(),
remote
);
}
}
#[test]
fn local_daemon_urls() {
for url in [
+5
View File
@@ -78,6 +78,11 @@ async fn run(base: PathBuf) -> i32 {
None
}
};
if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV)
&& let Err(e) = host.trust.inherit(std::path::Path::new(&root))
{
tracing::warn!("cannot trust {}: {e}", root.to_string_lossy());
}
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
let mut dispatcher = Dispatcher::standard();
if let Some(plugins) = &plugins {
+5 -1
View File
@@ -22,6 +22,10 @@ use std::time::Duration;
type Changes = Vec<(OsString, Option<OsString>)>;
/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands
/// (`devcontainer up`): the server then doesn't run `postStartCommand` again.
pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE";
/// What Reload Environment changed.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -72,7 +76,7 @@ impl Environment {
return None;
}
let config = self.load_project(root, sink);
let post_start = config.and_then(|c| c.post_start);
let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none());
let first = self.state.lock().post_started.insert(root.to_path_buf());
post_start.filter(|_| first)
}
+2
View File
@@ -223,6 +223,7 @@ impl Host {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
project.plugin_diagnostics.close();
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
@@ -373,6 +374,7 @@ impl Host {
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in &projects {
project.plugin_diagnostics.close();
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
+3 -2
View File
@@ -561,7 +561,8 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
file_action: r.file,
};
let plugin = r.plugin.clone();
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
let invocation = ide_plugin::Invocation { client: Some(window.to_owned()), ..Default::default() };
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending, invocation)).await?;
let root = host.project(window)?.root.clone();
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
}
@@ -586,7 +587,7 @@ pub async fn lsp_code_action_query(
Ok(result)
}
/// Resolves a code action's edit without applying it (intention preview).
/// Resolves a code action's edit without applying it.
pub async fn lsp_resolve_code_action(
host: &Host,
window: &str,
+13 -2
View File
@@ -181,6 +181,16 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_tomcat_installations(
host: &Host,
window: &str,
) -> IdeResult<Vec<ide_run::tomcat::TomcatInstallation>> {
host.require_trust(window)?;
tokio::task::spawn_blocking(ide_run::tomcat::detect_installations)
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
host.require_trust(window)?;
let project = host.project(window)?;
@@ -212,7 +222,7 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
&project.root,
&model,
id,
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
@@ -278,7 +288,7 @@ pub async fn run_tomcat_sync(
let module = dunce::canonicalize(project.root.join(module))?;
let class_paths: Vec<_> =
class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect();
let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id);
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
})
.await
@@ -297,6 +307,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await
});
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
d.add("run_tomcat_installations", |c| async move { run_tomcat_installations(&c.host, &c.window).await });
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
d.add("run_tomcat_sync", |c| async move {
+760 -32
View File
@@ -8,11 +8,13 @@ use ide_extension::Package;
use ide_language::ProgramPin;
use ide_plugin::{
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
ProcessChunk, ProcessCommand, ProcessOutput, Prompt, RepositoryStatus, StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command as StdCommand, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
@@ -49,6 +51,17 @@ pub trait PluginWorkspace: Send + Sync {
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
None
}
/// Current index of the project rooted at `root`; never reads disk or calls LSP.
fn symbols(
&self,
_root: &Path,
_query: &str,
_limit: usize,
_cancel: &CancellationToken,
_deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
Err(HostError::NotFound("no project index is available".into()))
}
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
@@ -124,6 +137,10 @@ impl SecretStore for FileSecretStore {
const MAX_RESPONSE: usize = 8 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 4 << 20;
/// Most processes one plugin keeps running at once.
const MAX_PROCESSES: usize = 8;
/// Most output one stream of a handle holds until the plugin reads it.
const MAX_PENDING: usize = 1 << 20;
/// Most files `find-files` walks before giving up.
const MAX_WALK: usize = 200_000;
@@ -167,6 +184,7 @@ struct TaskEntry {
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
struct PromptEntry {
plugin: String,
client: String,
answer: Waiter,
}
@@ -175,6 +193,218 @@ enum Waiter {
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
}
/// A process a plugin started and has not released (API 1.3 `process.start`).
/// Two reader threads copy its output into bounded buffers; the handle lives
/// as long as the plugin instance that started it.
struct Proc {
client: Option<String>,
state: Mutex<ProcState>,
}
struct ProcState {
child: Child,
stdin: Option<ChildStdin>,
/// What it printed and the plugin hasn't read yet.
stdout: Vec<u8>,
stderr: Vec<u8>,
/// Its exit status once it ended (a code, or -1 when a signal ended it).
status: Option<i32>,
/// Reader threads still copying its pipes into the buffers above.
readers: usize,
}
/// Which pipe a reader thread drains.
#[derive(Clone, Copy)]
enum Stream {
Out,
Err,
}
impl Proc {
/// Copies `pipe` into the matching buffer until it closes, keeping the last
/// [`MAX_PENDING`] bytes, then notes that the reader finished.
fn read(self: &Arc<Self>, mut pipe: impl Read + Send + 'static, stream: Stream) {
self.state.lock().readers += 1;
let this = Arc::clone(self);
std::thread::spawn(move || {
let mut buf = [0u8; 8 << 10];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let mut state = this.state.lock();
let out = match stream {
Stream::Out => &mut state.stdout,
Stream::Err => &mut state.stderr,
};
out.extend_from_slice(&buf[..n]);
let over = out.len().saturating_sub(MAX_PENDING);
if over > 0 {
out.drain(..over);
let continuation = out.iter().take_while(|b| (**b & 0xC0) == 0x80).count();
out.drain(..continuation);
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
this.state.lock().readers -= 1;
});
}
/// Notes its exit status if it ended (dropping its stdin then).
fn reap(&self) -> Result<(), HostError> {
let mut state = self.state.lock();
if state.status.is_none() {
match try_wait_process(&mut state.child) {
Ok(Some(status)) => {
state.status = Some(status.code().unwrap_or(-1));
state.stdin = None;
}
Ok(None) => {}
Err(e) => return Err(failed(e)),
}
}
Ok(())
}
/// Ends it and reaps it, so its status is known right away; killing twice
/// does nothing.
fn kill(&self) {
let mut state = self.state.lock();
state.stdin = None;
if state.status.is_none() {
ide_core::process::kill_tree(&mut state.child);
if let Ok(status) = state.child.wait() {
state.status = Some(status.code().unwrap_or(-1));
}
}
}
/// What it printed since the last call, each stream at most `max` bytes.
fn chunk(&self, max: usize) -> ProcessChunk {
let mut state = self.state.lock();
let eof = state.readers == 0;
ProcessChunk {
stdout: take_text(&mut state.stdout, max, eof),
stderr: take_text(&mut state.stderr, max, eof),
status: state.status,
}
}
}
/// Decodes complete characters within the byte budget, retaining partial UTF-8
/// until the reader gets the rest (or EOF). Even a tiny budget makes progress.
fn take_text(buf: &mut Vec<u8>, max: usize, eof: bool) -> String {
let limit = max.clamp(1, MAX_PENDING);
let mut out = String::new();
let mut consumed = 0;
while consumed < buf.len() && out.len() < limit {
let bytes = &buf[consumed..];
let (valid, invalid) = match std::str::from_utf8(bytes) {
Ok(text) => (text.len(), None),
Err(e) => (e.valid_up_to(), Some(e.error_len())),
};
let text = std::str::from_utf8(&bytes[..valid]).expect("valid prefix");
let mut end = text.len().min(limit - out.len());
while !text.is_char_boundary(end) {
end -= 1;
}
if end == 0 && out.is_empty() && !text.is_empty() {
end = text.chars().next().unwrap().len_utf8();
}
out.push_str(&text[..end]);
consumed += end;
if end < valid {
break;
}
match invalid {
Some(len) if len.is_some() || eof => {
if !out.is_empty() && out.len() + '\u{fffd}'.len_utf8() > limit {
break;
}
out.push('\u{fffd}');
consumed += len.unwrap_or(bytes.len() - valid);
}
_ => break,
}
}
buf.drain(..consumed);
out
}
/// Ends descendants before reaping an exited leader, while its process id is
/// still reserved. Otherwise inherited pipes can keep a completed call waiting.
fn try_wait_process(child: &mut Child) -> std::io::Result<Option<std::process::ExitStatus>> {
#[cfg(unix)]
{
use rustix::process::{Pid, WaitId, WaitIdOptions, waitid};
let status = waitid(
WaitId::Pid(Pid::from_child(child)),
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
)?;
if status.is_none() {
return Ok(None);
}
ide_core::process::kill_tree(child);
child.try_wait()
}
#[cfg(not(unix))]
{
let status = child.try_wait()?;
if status.is_some() {
ide_core::process::kill_tree(child);
}
Ok(status)
}
}
fn env_name_eq(a: &std::ffi::OsStr, b: &std::ffi::OsStr) -> bool {
#[cfg(windows)]
{
// Use the platform's Command key comparison, including non-ASCII names.
let mut probe = StdCommand::new("unused");
probe.env(a, "").env(b, "");
probe.get_envs().count() == 1
}
#[cfg(not(windows))]
{
a == b
}
}
/// Validates against the environment after `prepare`, then freezes that snapshot
/// so only new keys are added, including when the host environment changes later.
fn append_process_env(command: &mut StdCommand, env: &[(String, String)]) -> Result<(), HostError> {
if env.len() > 64 {
return Err(HostError::Invalid("at most 64 environment variables".into()));
}
let mut inherited: Vec<_> = std::env::vars_os().collect();
for (key, value) in command.get_envs() {
inherited.retain(|(k, _)| !env_name_eq(k, key));
if let Some(value) = value {
inherited.push((key.to_owned(), value.to_owned()));
}
}
for (key, value) in env {
if key.is_empty() || key.len() > 256 || key.contains(['=', '\0']) {
return Err(HostError::Invalid(format!("{key:?} is not an environment variable name")));
}
if value.len() > 4096 || value.contains('\0') {
return Err(HostError::Invalid(format!("the value of {key:?} is too long or holds a NUL")));
}
if inherited.iter().any(|(k, _)| env_name_eq(k, std::ffi::OsStr::new(key))) {
return Err(HostError::Invalid(format!(
"environment variable {key:?} already exists; only new variables may be added"
)));
}
inherited.push((key.into(), value.into()));
}
command.env_clear().envs(inherited);
Ok(())
}
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
@@ -183,6 +413,10 @@ pub(super) struct Shared {
pub data_dir: PathBuf,
tasks: Mutex<HashMap<u64, TaskEntry>>,
next_task: AtomicU64,
/// (plugin, handle id) → the processes it started and has not released.
processes: Mutex<HashMap<(String, u64), Arc<Proc>>>,
next_process: AtomicU64,
process_shutdowns: Mutex<Vec<std::thread::JoinHandle<()>>>,
prompts: Mutex<HashMap<u64, PromptEntry>>,
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
prompts_blocked: Mutex<BTreeSet<String>>,
@@ -222,6 +456,9 @@ impl Shared {
data_dir,
tasks: Mutex::default(),
next_task: AtomicU64::new(1),
processes: Mutex::default(),
next_process: AtomicU64::new(1),
process_shutdowns: Mutex::default(),
prompts: Mutex::default(),
prompts_blocked: Mutex::default(),
next_prompt: AtomicU64::new(1),
@@ -286,11 +523,41 @@ impl Shared {
.collect()
}
/// Drops the context keys scoped to window `client` (it closed), without events, and
/// refuses further writes for it (late commands) until [`Self::reopen_client`].
/// Cancels prompts and drops context keys scoped to window `client` (it closed).
/// Refuses further writes for it (late commands) until [`Self::reopen_client`].
pub fn forget_client(&self, client: &str) {
self.closed_clients.lock().insert(client.to_owned());
let mut closed = self.closed_clients.lock();
closed.insert(client.to_owned());
self.prompts.lock().retain(|_, entry| entry.client != client);
drop(closed);
self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client));
let mine: Vec<_> = {
let mut processes = self.processes.lock();
let keys: Vec<_> = processes
.iter()
.filter(|(_, p)| p.client.as_deref() == Some(client))
.map(|(key, _)| key.clone())
.collect();
keys.iter().filter_map(|key| processes.remove(key)).collect()
};
if !mine.is_empty() {
// Window destruction runs on the UI thread; tree shutdown may wait.
let worker = std::thread::spawn(move || {
for proc in mine {
proc.kill();
}
});
let mut workers = self.process_shutdowns.lock();
workers.retain(|worker| !worker.is_finished());
workers.push(worker);
}
}
pub fn finish_process_shutdowns(&self) {
let workers = std::mem::take(&mut *self.process_shutdowns.lock());
for worker in workers {
let _ = worker.join();
}
}
/// A window labelled `client` is being created (labels are reused after a close).
@@ -298,9 +565,14 @@ impl Shared {
self.closed_clients.lock().remove(client);
}
pub fn client_closed(&self, client: &str) -> bool {
self.closed_clients.lock().contains(client)
}
/// Withdraws a plugin's run-time state (it stopped or failed). Its
/// subscriptions go only with `subscriptions`, as they survive restarts.
pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) {
self.kill_processes(plugin);
let removed: Vec<String> = {
let mut status = self.status.lock();
let ids: Vec<String> = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect();
@@ -341,6 +613,19 @@ impl Shared {
}
}
/// Ends every process `plugin` started: its handles go with its instance
/// (it stopped, was disabled, or was reclaimed while idle).
pub fn kill_processes(&self, plugin: &str) {
let mine: Vec<Arc<Proc>> = {
let mut processes = self.processes.lock();
let keys: Vec<(String, u64)> = processes.keys().filter(|(p, _)| p == plugin).cloned().collect();
keys.iter().filter_map(|k| processes.remove(k)).collect()
};
for proc in mine {
proc.kill();
}
}
/// The user cancelled a task in the UI.
pub fn cancel_task(&self, id: u64) -> bool {
let mut tasks = self.tasks.lock();
@@ -643,14 +928,18 @@ impl PluginApi {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
if closed.contains(client) {
return Err(HostError::Cancelled);
}
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.id().to_owned(), client: client.to_owned(), answer: Waiter::Commit(answer) },
);
}
let request_id = id.to_string();
self.shared.sink.emit(
@@ -676,6 +965,23 @@ impl PluginApi {
}
}
}
/// Where `cmd` runs: the project root it names, else the plugin's data
/// directory (created as needed).
fn spawn_dir(&self, cwd: Option<&Path>) -> PathBuf {
let dir = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&dir);
dir
}
/// The process handle `id`, if this plugin started it and it is still open.
fn process(&self, id: u64) -> Result<Arc<Proc>, HostError> {
self.shared
.processes
.lock()
.get(&(self.id().to_owned(), id))
.cloned()
.ok_or_else(|| HostError::NotFound(format!("no process {id}: it was closed, or belongs to another plugin")))
}
}
/// The changes `selection` commits, diffed repository by repository.
@@ -769,14 +1075,18 @@ impl HostApi for PluginApi {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
if closed.contains(client) {
return Err(HostError::Cancelled);
}
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.id().to_owned(), client: client.to_owned(), answer: Waiter::Prompt(answer) },
);
}
let prompt_id = id.to_string();
self.shared.sink.emit(
@@ -1002,6 +1312,26 @@ impl HostApi for PluginApi {
}
}
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
if query.len() > 256 {
return Err(HostError::Invalid("symbol query exceeds 256 UTF-8 bytes".into()));
}
self.shared.workspace().ok_or_else(|| HostError::NotFound("no project index is available".into()))?.symbols(
root,
query,
limit.min(1000),
cancel,
deadline,
)
}
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
let matcher = globset::GlobBuilder::new(glob)
.literal_separator(true)
@@ -1112,22 +1442,24 @@ impl HostApi for PluginApi {
fn run_process(
&self,
program: &str,
args: Vec<String>,
cmd: ProcessCommand,
cwd: Option<&Path>,
stdin: Option<String>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError> {
let binary = self.approved_program(program)?;
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&cwd);
let mut child = std::process::Command::new(&binary)
.args(&args)
let binary = self.approved_program(&cmd.program)?;
let cwd = self.spawn_dir(cwd);
let mut command = StdCommand::new(&binary);
ide_core::process::prepare(&mut command);
append_process_env(&mut command, &cmd.env)?;
ide_core::process::own_group(&mut command);
let mut child = command
.args(&cmd.args)
.current_dir(&cwd)
.stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() })
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) {
@@ -1136,30 +1468,36 @@ impl HostApi for PluginApi {
let _ = pipe.write_all(input.as_bytes());
});
}
let read = |pipe: Option<Box<dyn std::io::Read + Send>>| {
let read = |pipe: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
use std::io::Read;
let mut out = Vec::new();
if let Some(p) = pipe {
let _ = p.take(MAX_OUTPUT as u64).read_to_end(&mut out);
let mut p = p;
let _ = p.by_ref().take(MAX_OUTPUT as u64).read_to_end(&mut out);
let _ = std::io::copy(&mut p, &mut std::io::sink());
}
String::from_utf8_lossy(&out).into_owned()
let mut text = String::from_utf8_lossy(&out).into_owned();
let mut end = text.len().min(MAX_OUTPUT);
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
text
})
};
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = child.try_wait().map_err(|e| HostError::Failed(e.to_string()))? {
if let Some(status) = try_wait_process(&mut child).map_err(|e| HostError::Failed(e.to_string()))? {
break status;
}
if cancel.is_cancelled() || Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
ide_core::process::kill_tree(&mut child);
return Err(if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed(format!("{program} timed out after {timeout:?}"))
HostError::Failed(format!("{} timed out after {timeout:?}", cmd.program))
});
}
std::thread::sleep(Duration::from_millis(10));
@@ -1190,6 +1528,169 @@ impl HostApi for PluginApi {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result<u64, HostError> {
let binary = self.approved_program(&cmd.program)?;
let cwd = self.spawn_dir(cwd);
if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) {
return Err(HostError::Cancelled);
}
{
let processes = self.shared.processes.lock();
let running = processes.keys().filter(|(plugin, _)| plugin == self.id()).count();
if running >= MAX_PROCESSES {
return Err(HostError::Invalid(format!(
"at most {MAX_PROCESSES} processes may run at once: release one first"
)));
}
}
let mut command = StdCommand::new(&binary);
ide_core::process::prepare(&mut command);
append_process_env(&mut command, &cmd.env)?;
ide_core::process::own_group(&mut command);
let mut child = command
.args(&cmd.args)
.current_dir(&cwd)
// Always piped: `write` may come later, and a plugin that never
// writes closes it (or loses it when it releases the handle).
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
let (stdout, stderr) = (child.stdout.take(), child.stderr.take());
let proc = Arc::new(Proc {
client: client.map(str::to_owned),
state: Mutex::new(ProcState {
stdin: child.stdin.take(),
child,
stdout: Vec::new(),
stderr: Vec::new(),
status: None,
readers: 0,
}),
});
if let Some(pipe) = stdout {
proc.read(pipe, Stream::Out);
}
if let Some(pipe) = stderr {
proc.read(pipe, Stream::Err);
}
let closed = self.shared.closed_clients.lock();
if client.is_some_and(|c| closed.contains(c)) {
drop(closed);
proc.kill();
return Err(HostError::Cancelled);
}
let id = self.shared.next_process.fetch_add(1, Ordering::Relaxed);
self.shared.processes.lock().insert((self.id().to_owned(), id), proc);
drop(closed);
Ok(id)
}
fn poll_process(&self, id: u64, max: usize) -> Result<ProcessChunk, HostError> {
let proc = self.process(id)?;
proc.reap()?;
Ok(proc.chunk(max))
}
fn write_process(
&self,
id: u64,
text: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let proc = self.process(id)?;
proc.reap()?;
let mut pipe = {
let mut state = proc.state.lock();
if state.status.is_some() {
return Err(HostError::Failed("the process already ended".into()));
}
state.stdin.take().ok_or_else(|| HostError::Failed("its stdin is closed".into()))?
};
let input = text.to_owned();
let writer = proc.clone();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
use std::io::Write;
let result = pipe.write_all(input.as_bytes()).and_then(|()| pipe.flush());
let mut state = writer.state.lock();
if state.status.is_none() {
state.stdin = Some(pipe);
}
let _ = tx.send(result);
});
let deadline = Instant::now() + timeout;
loop {
if cancel.is_cancelled() || Instant::now() >= deadline {
proc.kill();
return Err(if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed("stdin write timed out".into())
});
}
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(result) => return result.map_err(|e| HostError::Failed(format!("stdin: {e}"))),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => return Err(HostError::Failed("stdin writer stopped".into())),
}
}
}
fn close_process_stdin(&self, id: u64) -> Result<(), HostError> {
let proc = self.process(id)?;
proc.reap()?;
let mut state = proc.state.lock();
if state.status.is_some() {
return Err(HostError::Failed("the process already ended".into()));
}
state.stdin = None;
Ok(())
}
fn release_process(&self, id: u64) {
let proc = self.shared.processes.lock().remove(&(self.id().to_owned(), id));
if let Some(proc) = proc {
proc.kill();
}
}
fn kill_process(&self, id: u64) -> Result<(), HostError> {
self.process(id)?.kill();
Ok(())
}
fn wait_process(
&self,
id: u64,
max: usize,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<ProcessChunk, HostError> {
let proc = self.process(id)?;
let deadline = Instant::now() + timeout;
loop {
proc.reap()?;
let (ended, readers) = {
let state = proc.state.lock();
(state.status.is_some(), state.readers)
};
// Its output is only complete once its pipes reached the end too.
if ended && readers == 0 {
break;
}
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
if Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
Ok(proc.chunk(max))
}
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64 {
let id = self.shared.next_task.fetch_add(1, Ordering::Relaxed);
@@ -1406,6 +1907,57 @@ impl PluginWorkspace for HostWorkspace {
project.index.files_matching(root, matches, limit)
}
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
let project = HostWorkspace::projects(self)
.into_iter()
.find(|p| p.root == root)
.ok_or_else(|| HostError::NotFound("the project is no longer open".into()))?;
let stopped = || cancel.is_cancelled() || Instant::now() >= deadline;
let interrupted = || {
if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed("symbol search timed out".into())
}
};
let symbols = project
.index
.find_symbols_until(
query,
ide_index::SymbolFilter::All,
limit.min(1000),
&|path| path.starts_with(root),
&stopped,
)
.ok_or_else(interrupted)?;
let mut out = Vec::new();
for s in symbols {
if stopped() {
return Err(interrupted());
}
out.push(ide_plugin::SymbolInfo {
name: s.name,
kind: serde_json::to_value(s.kind)
.ok()
.and_then(|k| k.as_str().map(str::to_owned))
.unwrap_or_else(|| "unknown".into()),
container: s.container,
location: ide_plugin::Location { path: s.path.0, range: s.range },
});
}
if stopped() {
return Err(interrupted());
}
Ok(out)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let (project, doc) = self.open_document(path)?;
let snap = project.documents.snapshot(doc).ok()?;
@@ -1631,6 +2183,182 @@ mod tests {
);
}
#[test]
fn process_environment_only_adds_variables() {
let tmp = tempfile::tempdir().unwrap();
let binary = ide_core::process::find_executable(
"git",
&ide_core::process::user_path().or_else(|| std::env::var_os("PATH")).unwrap(),
)
.expect("git on PATH");
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
pins.write().insert(("acme.lint".into(), "process:git".into()), ProgramPin::approve(&binary).unwrap());
let api = api(tmp.path(), pins);
let inherited = std::env::vars_os()
.find(|(key, _)| key != "PATH" && key.to_str().is_some_and(|key| !key.is_empty() && !key.contains('=')))
.unwrap()
.0
.into_string()
.unwrap();
for key in ["PATH".to_owned(), inherited] {
let cmd = ProcessCommand {
program: "git".into(),
env: vec![(key.clone(), "replacement".into())],
..Default::default()
};
let err = api
.run_process(cmd.clone(), None, None, &CancellationToken::new(), Duration::from_secs(5))
.unwrap_err();
assert!(matches!(err, HostError::Invalid(ref message) if message.contains(&key)), "{err}");
let err = api.start_process(cmd, None, None).unwrap_err();
assert!(matches!(err, HostError::Invalid(ref message) if message.contains(&key)), "{err}");
}
}
#[test]
fn process_environment_uses_prepared_values_and_windows_key_rules() {
let mut cmd = StdCommand::new("unused");
ide_core::process::prepare(&mut cmd);
cmd.env("OPENIDEA_PREPARED_ENV", "login-shell");
let err = append_process_env(&mut cmd, &[("OPENIDEA_PREPARED_ENV".into(), "override".into())]).unwrap_err();
assert!(err.to_string().contains("OPENIDEA_PREPARED_ENV"));
for name in ["", "BAD=KEY", "BAD\0KEY"] {
assert!(append_process_env(&mut cmd, &[(name.into(), "value".into())]).is_err());
}
for value in ["bad\0value".to_owned(), "x".repeat(4097)] {
assert!(append_process_env(&mut cmd, &[("OPENIDEA_NEW_ENV".into(), value)]).is_err());
}
assert!(
append_process_env(
&mut cmd,
&[("OPENIDEA_NEW_ENV".into(), "a".into()), ("OPENIDEA_NEW_ENV".into(), "b".into())]
)
.is_err()
);
assert_eq!(env_name_eq("PATH".as_ref(), "Path".as_ref()), cfg!(windows));
#[cfg(windows)]
{
assert!(append_process_env(&mut cmd, &[("pAtH".into(), "override".into())]).is_err());
assert!(env_name_eq("Ä_KEY".as_ref(), "ä_key".as_ref()));
}
cmd.env_remove("OPENIDEA_REMOVED_ENV");
append_process_env(&mut cmd, &[("OPENIDEA_REMOVED_ENV".into(), "added".into())]).unwrap();
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "OPENIDEA_REMOVED_ENV").unwrap().1, Some("added".as_ref()));
}
#[test]
fn process_output_preserves_partial_utf8_and_bounds_replacement_text() {
let mut bytes = vec![0xe4, 0xb8];
assert_eq!(take_text(&mut bytes, 1, false), "");
bytes.extend_from_slice(&[0xad, b'x']);
assert_eq!(take_text(&mut bytes, 1, false), "中");
assert_eq!(take_text(&mut bytes, 0, false), "x");
let mut bytes = vec![0xff; MAX_PENDING];
let text = take_text(&mut bytes, MAX_PENDING, true);
assert!(text.len() <= MAX_PENDING);
assert!(!bytes.is_empty());
assert_eq!(take_text(&mut vec![0xe4, 0xb8], 8, true), "�");
}
#[cfg(unix)]
fn shell_api(dir: &Path) -> PluginApi {
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
pins.write()
.insert(("acme.lint".into(), "process:sh".into()), ProgramPin::approve(Path::new("/bin/sh")).unwrap());
api(dir, pins)
}
#[cfg(unix)]
fn shell(script: &str) -> ProcessCommand {
ProcessCommand { program: "sh".into(), args: vec!["-c".into(), script.into()], env: Vec::new() }
}
#[cfg(unix)]
#[test]
fn process_handles_are_bounded_scoped_and_released() {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let mut ids = Vec::new();
for _ in 0..MAX_PROCESSES {
ids.push(api.start_process(shell("exec cat"), None, Some("main")).unwrap());
}
assert!(matches!(api.start_process(shell("exec cat"), None, None), Err(HostError::Invalid(_))));
let proc = api.process(ids[0]).unwrap();
api.release_process(ids[0]);
assert!(proc.state.lock().child.try_wait().unwrap().is_some());
assert!(api.process(ids[0]).is_err());
let other = api.start_process(shell("exec cat"), None, Some("other")).unwrap();
api.shared.forget_client("main");
assert!(api.process(ids[1]).is_err());
assert!(api.process(other).is_ok());
assert!(matches!(api.start_process(shell("exec cat"), None, Some("main")), Err(HostError::Cancelled)));
api.shared.kill_processes(api.id());
assert!(api.shared.processes.lock().is_empty());
}
#[cfg(unix)]
#[test]
fn process_stdin_timeout_and_stop_do_not_deadlock() {
let tmp = tempfile::tempdir().unwrap();
let api = Arc::new(shell_api(tmp.path()));
for stop in [false, true] {
let id = api.start_process(shell("exec sleep 60"), None, Some("main")).unwrap();
let proc = api.process(id).unwrap();
let writer = api.clone();
let thread = std::thread::spawn(move || {
writer.write_process(id, &"x".repeat(1 << 20), &CancellationToken::new(), Duration::from_millis(150))
});
if stop {
std::thread::sleep(Duration::from_millis(30));
api.shared.kill_processes(api.id());
}
assert!(thread.join().unwrap().is_err());
assert!(proc.state.lock().child.try_wait().unwrap().is_some());
api.release_process(id);
}
}
#[cfg(unix)]
#[test]
fn process_parent_exit_closes_descendant_pipes() {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let token = CancellationToken::new();
let before = Instant::now();
let out =
api.run_process(shell("sleep 60 & echo done"), None, None, &token, Duration::from_millis(300)).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!(out.stdout.trim(), "done");
assert!(before.elapsed() < Duration::from_secs(5));
let id = api.start_process(shell("sleep 60 & echo done"), None, None).unwrap();
let out = api.wait_process(id, MAX_PENDING, Duration::from_millis(300), &token).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!(out.stdout.trim(), "done");
assert_eq!(api.process(id).unwrap().state.lock().readers, 0);
api.release_process(id);
}
#[cfg(unix)]
#[test]
fn process_output_is_drained_and_retention_is_bounded() {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let token = CancellationToken::new();
let cmd = shell("head -c 5242880 /dev/zero; head -c 5242880 /dev/zero >&2");
let out = api.run_process(cmd.clone(), None, None, &token, Duration::from_secs(5)).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_OUTPUT, MAX_OUTPUT));
let id = api.start_process(cmd, None, None).unwrap();
let out = api.wait_process(id, MAX_PENDING, Duration::from_secs(5), &token).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_PENDING, MAX_PENDING));
let state = api.process(id).unwrap();
let state = state.state.lock();
assert!(state.stdout.is_empty() && state.stderr.is_empty());
drop(state);
api.release_process(id);
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
+245
View File
@@ -0,0 +1,245 @@
//! Versioned, project-scoped diagnostics for unsaved editor buffers.
use super::{PluginHost, ProviderKind, editor_plugin_host};
use ide_core::{CancellationToken, Diagnostic, DocId, IdeEvent, SharedSink};
use ide_plugin::ProviderDocument;
use ide_text::AppliedChange;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents};
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Weak};
use std::time::Duration;
const DEBOUNCE: Duration = Duration::from_millis(300);
struct Document {
snapshot: ProviderDocument,
cancel: CancellationToken,
sources: HashSet<String>,
}
#[derive(Default)]
pub(super) struct State {
closed: bool,
documents: HashMap<DocId, Document>,
generations: HashMap<String, u64>,
}
/// One incarnation of one project's documents, shared by all its editors.
pub(crate) struct Diagnostics {
state: Arc<Mutex<State>>,
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: Weak<Documents>,
root: std::path::PathBuf,
}
impl Diagnostics {
pub fn new(
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: &Arc<Documents>,
root: &Path,
) -> Arc<Self> {
Arc::new(Self {
state: Arc::default(),
sink,
runtime,
documents: Arc::downgrade(documents),
root: root.to_path_buf(),
})
}
fn emit(&self, path: &Path, plugin: &str, items: Vec<Diagnostic>) {
self.sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin-provider:{plugin}"),
items,
});
}
fn clear_document(&self, doc: &Document) {
doc.cancel.cancel();
for plugin in &doc.sources {
self.emit(&doc.snapshot.path, plugin, Vec::new());
}
}
pub fn close(&self) {
let mut state = self.state.lock();
state.closed = true;
for (_, doc) in state.documents.drain() {
self.clear_document(&doc);
}
}
fn schedule(&self, id: DocId, state: &mut State) {
let Some(doc) = state.documents.get_mut(&id) else { return };
doc.cancel.cancel();
doc.cancel = CancellationToken::new();
let Some(host) = editor_plugin_host() else { return };
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
return;
}
// Register only once per project incarnation; no project is retained by the host.
let weak = Arc::downgrade(&self.state);
{
let mut projects = host.diagnostic_projects.lock();
projects.retain(|(state, _)| state.strong_count() > 0);
if !projects.iter().any(|(state, _)| state.ptr_eq(&weak)) {
projects.push((weak.clone(), self.sink.clone()));
}
}
let root = self.root.clone();
let (mut snapshot, cancel, sink, documents) =
(doc.snapshot.clone(), doc.cancel.clone(), self.sink.clone(), self.documents.clone());
let lifecycle_generations = state.generations.clone();
self.runtime.spawn(async move {
tokio::time::sleep(DEBOUNCE).await;
if cancel.is_cancelled() || weak.strong_count() == 0 {
return;
}
let _ = tokio::task::spawn_blocking(move || {
let Some(documents) = documents.upgrade() else { return };
let Ok(current) = documents.snapshot(id) else { return };
if u64::from(current.version) != snapshot.version
|| current.path.0 != snapshot.path
|| current.language.as_str() != snapshot.language
|| cancel.is_cancelled()
{
return;
}
snapshot.text = current.text;
drop(documents);
let generations = host.providers.diagnostic_generations(&snapshot.language);
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
} else {
Vec::new()
};
let Some(state) = weak.upgrade() else { return };
let mut state = state.lock();
if state.closed || cancel.is_cancelled() {
return;
}
let matching: HashSet<_> = host
.diagnostic_plugins(Some(&root), &snapshot.language)
.into_iter()
.map(|p| p.id().to_owned())
.filter(|p| lifecycle_generations.get(p) == state.generations.get(p))
.collect();
let latest = host.providers.diagnostic_generations(&snapshot.language);
let Some(doc) = state.documents.get_mut(&id) else { return };
if doc.snapshot.version != snapshot.version || doc.snapshot.path != snapshot.path {
return;
}
let mut current = HashSet::new();
for (plugin, items) in results {
if !matching.contains(&plugin)
|| !generations.get(&plugin).is_some_and(|seq| latest.get(&plugin) == Some(seq))
{
continue;
}
current.insert(plugin.clone());
sink.emit(IdeEvent::Diagnostics {
path: snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items,
});
}
for plugin in doc.sources.difference(&current) {
sink.emit(IdeEvent::Diagnostics {
path: snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items: Vec::new(),
});
}
doc.sources = current;
})
.await;
});
}
}
impl DocumentObserver for Diagnostics {
fn opened(&self, info: &DocumentInfo) {
let mut state = self.state.lock();
if state.closed {
return;
}
if let Some(old) = state.documents.remove(&info.id) {
self.clear_document(&old);
}
if info.large || info.uri.is_some() {
return;
}
state.documents.insert(
info.id,
Document {
snapshot: ProviderDocument {
path: info.path.0.clone(),
language: info.language.to_string(),
version: u64::from(info.version),
text: String::new(),
},
cancel: CancellationToken::new(),
sources: HashSet::new(),
},
);
self.schedule(info.id, &mut state);
}
fn changed(&self, id: DocId, path: &Path, version: u32, _: &[AppliedChange], _text: &dyn Fn() -> String) {
let mut state = self.state.lock();
if state.closed {
return;
}
let Some(doc) = state.documents.get_mut(&id) else { return };
if doc.snapshot.path != path {
self.clear_document(doc);
doc.sources.clear();
}
doc.snapshot.path = path.to_path_buf();
doc.snapshot.version = u64::from(version);
self.schedule(id, &mut state);
}
fn closed(&self, id: DocId, _: &Path) {
if let Some(doc) = self.state.lock().documents.remove(&id) {
self.clear_document(&doc);
}
}
}
impl Drop for Diagnostics {
fn drop(&mut self) {
self.close();
}
}
pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
impl PluginHost {
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
// Clone before taking a project's state lock (edits register in the other order).
let projects = self.diagnostic_projects.lock().clone();
for (state, sink) in projects {
if let Some(state) = state.upgrade() {
let mut state = state.lock();
// A completed answer can still be waiting for another provider.
*state.generations.entry(plugin.to_owned()).or_default() += 1;
for doc in state.documents.values_mut() {
if doc.sources.remove(plugin) {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items: Vec::new(),
});
}
}
}
}
}
}
+6
View File
@@ -510,6 +510,8 @@ fn check_value(key: &str, value: &str) -> Result<(), String> {
if lower.contains("javascript:")
|| lower.contains("expression(")
|| lower.contains("@import")
// These CSS image/URL functions accept bare strings, bypassing url().
|| ["image(", "image-set(", "src("].iter().any(|function| lower.contains(function))
|| value.contains('\\')
{
return Err("unsafe value".into());
@@ -607,6 +609,10 @@ mod tests {
r##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
r##"<svg><rect style="background-image:image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:-webkit-image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:image('https://evil/x.png')"/></svg>"##,
r##"<svg><rect style="fill:src('https://evil/x.svg')"/></svg>"##,
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
r##"<svg><![CDATA[x]]></svg>"##,
r##"<svg><a href="#x"/></svg>"##,
+96 -15
View File
@@ -17,6 +17,7 @@
mod api;
pub mod commit;
pub(crate) mod diagnostics;
pub mod endpoints;
mod event_paths;
mod hover;
@@ -326,6 +327,8 @@ pub struct PluginHost {
providers: Arc<providers::Providers>,
navigation: Mutex<navigation::NavigationIndex>,
endpoints: Mutex<endpoints::Endpoints>,
web_ptys: Mutex<BTreeMap<String, Vec<web::WebPtySession>>>,
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -382,7 +385,8 @@ impl PluginHost {
let ui = UiStore::new();
let index = Arc::new(ContributionIndex::new(
plugins_dir.join(".cache").join("contributions"),
env!("CARGO_PKG_VERSION"),
// Revalidate SVGs accepted before URL-valued CSS functions were rejected.
concat!(env!("CARGO_PKG_VERSION"), ":svg-2"),
));
ui.set_index(Some(index.clone()));
let languages = PluginLanguages::new();
@@ -416,6 +420,8 @@ impl PluginHost {
providers,
navigation: Mutex::default(),
endpoints: Mutex::default(),
web_ptys: Mutex::default(),
diagnostic_projects: Mutex::default(),
index,
plugins: RwLock::default(),
state: Mutex::new(state),
@@ -1096,8 +1102,7 @@ impl PluginHost {
})
}
/// The Services pane of node `key` of the plugin's `location = "services"`
/// view `view`.
/// The detail tabs of item `key` of a Services or `detail = true` view.
pub fn service_detail(
&self,
id: &str,
@@ -1105,9 +1110,9 @@ impl PluginHost {
key: &str,
invocation: Invocation,
) -> IdeResult<Vec<ide_plugin::ServiceTab>> {
self.services_view(id, view)?;
self.detail_view(id, view)?;
let (view, key) = (view.to_owned(), key.to_owned());
self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| {
self.code_call(id, invocation, "rendering the item's detail pane", move |rt, inv| {
rt.service_detail(&view, &key, inv)
})
}
@@ -1163,16 +1168,16 @@ impl PluginHost {
if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) }
}
fn services_view(&self, id: &str, view: &str) -> IdeResult<()> {
let services = self
.ui
.items(ui_points::VIEWS, Some(id))
.iter()
.any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION));
if services {
fn detail_view(&self, id: &str, view: &str) -> IdeResult<()> {
let detail = self.ui.items(ui_points::VIEWS, Some(id)).iter().any(|i| {
i.key == view
&& (i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION)
|| i.decl["detail"].as_bool() == Some(true))
});
if detail {
Ok(())
} else {
Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}")))
Err(IdeError::not_found(format!("plugin {id:?} declares no detail view {view:?}")))
}
}
@@ -1365,11 +1370,12 @@ impl PluginHost {
self.shared.reopen_client(label);
}
/// Window `label` closed: drops its context keys and queued events, and ignores
/// context writes of commands still running for it.
/// Window `label` closed: cancels its prompts, drops its context keys and queued
/// events, and ignores context writes of commands still running for it.
pub fn forget_client(&self, label: &str) {
self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label));
self.shared.forget_client(label);
self.forget_web_ptys(label);
}
/// The user cancelled a plugin task (`task_id` from `TaskStarted`).
@@ -1762,6 +1768,7 @@ impl PluginHost {
let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect();
for id in self.plugins.read().keys() {
self.shared.block_prompts(id);
self.stop_web_ptys(id);
}
for a in &actors {
a.stop(false);
@@ -1769,6 +1776,7 @@ impl PluginHost {
for a in actors {
a.stop(true);
}
self.shared.finish_process_shutdowns();
}
/// Periodic upkeep (every few seconds): reports crashes nobody waited
@@ -1937,6 +1945,7 @@ impl PluginHost {
/// whose activation event already fired.
fn start(&self, plugin: &Arc<LoadedPlugin>) {
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
if plugin.source == PluginSource::Installed
&& let Some(reason) = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir())
{
@@ -2119,6 +2128,7 @@ impl PluginHost {
/// A trap dropped the plugin's instance: restart it on next use after a
/// backoff, or stop it for the session after too many crashes in a row.
fn crashed(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
self.clear_provider_diagnostics(plugin.id());
let failures = {
let mut meta = plugin.meta.lock();
meta.failures += 1;
@@ -2165,6 +2175,8 @@ impl PluginHost {
*runtime = None;
}
actor.stop(false);
// Its handles go with its instance; an idle reclaim is one.
self.shared.kill_processes(plugin.id());
let mut meta = plugin.meta.lock();
if meta.status == PluginStatus::Active {
meta.status = PluginStatus::Inactive;
@@ -2175,6 +2187,7 @@ impl PluginHost {
}
fn stop(&self, plugin: &LoadedPlugin) {
self.stop_web_ptys(plugin.id());
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
// A call waiting on the user would keep the join below waiting.
@@ -2183,6 +2196,7 @@ impl PluginHost {
actor.stop(true);
}
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
self.shared.clear_plugin(plugin.id(), true);
self.queues.lock().retain(|(id, _), _| id != plugin.id());
}
@@ -2192,9 +2206,12 @@ impl PluginHost {
self.clear_plugin_endpoints(plugin.id());
tracing::warn!(plugin = %plugin.id(), "{error}");
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Failed;
meta.error = Some(error);
drop(meta);
self.stop_web_ptys(plugin.id());
}
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
@@ -2558,6 +2575,70 @@ impl ide_language::ExternalSyntax for PluginSyntax {
mod tests {
use super::*;
#[test]
fn host_panics_stop_actor_and_reply_on_every_call_path() {
let tmp = tempfile::tempdir().unwrap();
let host =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
let plugin = host.get("test.diagnostics").unwrap();
for mode in 0..5 {
let (actor, _) = host.ensure_active(&plugin, None).unwrap();
let cancel = ide_core::CancellationToken::new();
let next_actor = actor.clone();
let (queued_tx, queued_rx) = std::sync::mpsc::channel();
let panic = move |_: &mut Runtime| -> Result<(), CallError> {
let pending = next_actor.submit(None, |_| Ok("must not run")).unwrap();
queued_tx.send(pending).unwrap();
panic!("host call panic")
};
let result = match mode {
0 => actor.call(None, panic),
1 => actor.call_cancellable(&cancel, Duration::ZERO, panic),
2 => actor.call_edit_cancellable(&cancel, Duration::ZERO, Duration::from_secs(5), panic),
_ => {
let (tx, rx) = std::sync::mpsc::channel();
if mode == 3 {
actor.post(panic, move |result| {
let _ = tx.send(result);
});
} else {
actor.post(
|_| Ok(()),
move |result| {
let _ = tx.send(result);
panic!("completion callback panic");
},
);
}
rx.recv_timeout(Duration::from_secs(5)).expect("actor must reply")
}
};
if mode < 4 {
let queued = queued_rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(matches!(queued.wait(), Err(ActorError::Stopped)), "poisoned runtime must not run queued work");
}
actor.stop(true);
if mode < 4 {
assert!(
matches!(result, Err(ActorError::Call(CallError::Trap(ref message))) if message.contains("host call panic")),
"{result:?}"
);
}
assert!(!actor.is_alive());
assert!(!actor.is_busy());
assert!(matches!(actor.call(None, |_| Ok(())), Err(ActorError::Stopped)));
let crash = actor.take_crash().expect("supervisor must see the panic");
assert!(crash.contains(if mode == 4 { "completion callback panic" } else { "host call panic" }));
assert!(actor.take_crash().is_none());
cancel.cancel();
// Re-enable to reset the supervisor before testing the next call path.
host.set_enabled("test.diagnostics", false).unwrap();
host.set_enabled("test.diagnostics", true).unwrap();
}
}
#[test]
fn globs() {
assert!(glob_match("**/*Mapper.xml", "src/main/UserMapper.xml"));
+1
View File
@@ -67,6 +67,7 @@ pub fn register_builtin_points(
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
registry.register(Arc::new(UiPoint::new(p::MARKER_ICONS, ui.clone(), p::marker_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
+120 -10
View File
@@ -22,6 +22,7 @@
use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -40,6 +41,7 @@ pub enum ProviderKind {
SemanticTokens,
Hover,
InlayHints,
Diagnostics,
}
#[derive(Clone, Debug, Deserialize)]
@@ -73,13 +75,26 @@ impl Providers {
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (_, plugin, kinds, languages) in self.items.read().iter() {
if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) {
out.push(plugin.clone());
}
}
out
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
}
/// One coherent declaration snapshot, preserving the first match per plugin.
fn matching<T: FromIterator<(String, u64)>>(&self, kind: ProviderKind, language: &str) -> T {
let items = self.items.read();
let mut seen = HashSet::new();
items
.iter()
.filter(|(_, plugin, kinds, languages)| {
kinds.contains(&kind)
&& languages.iter().any(|l| l == "*" || l == language)
&& seen.insert(plugin.as_str())
})
.map(|(seq, plugin, ..)| (plugin.clone(), *seq))
.collect()
}
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
self.matching(ProviderKind::Diagnostics, language)
}
/// Whether any plugin provides `kind` (for any language).
@@ -118,6 +133,9 @@ impl ExtensionPoint for ProvidersPoint {
if d.kinds.contains(&ProviderKind::SemanticTokens) {
pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?;
}
if d.kinds.contains(&ProviderKind::Diagnostics) {
pkg.manifest.needs_api("contributes.providers: diagnostics", 3)?;
}
if d.kinds.contains(&ProviderKind::Hover) {
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
}
@@ -202,7 +220,7 @@ pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
impl PluginHost {
/// Usable plugins declaring `kind` for `language`.
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
pub(super) fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
self.providers
.plugins_for(kind, language)
.into_iter()
@@ -315,11 +333,12 @@ impl PluginHost {
plugin_id: &str,
doc: &ProviderDocument,
action: CodeAction,
invocation: ide_plugin::Invocation,
) -> 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)
self.code_call(plugin_id, invocation, "resolving the code action", move |rt, inv| {
rt.resolve_code_action(&d, &action, inv, timeout)
})
}
@@ -359,6 +378,59 @@ impl PluginHost {
.collect()
}
pub fn provide_diagnostics(
&self,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
self.provide_project_diagnostics(None, doc, cancel)
}
pub(super) fn diagnostic_plugins(
&self,
project: Option<&std::path::Path>,
language: &str,
) -> Vec<Arc<LoadedPlugin>> {
self.provider_plugins(ProviderKind::Diagnostics, language)
.into_iter()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.collect()
}
pub(super) fn provide_project_diagnostics(
&self,
project: Option<&std::path::Path>,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
std::thread::scope(|scope| {
let calls: Vec<_> = self
.diagnostic_plugins(project, &doc.language)
.into_iter()
.map(|p| {
scope.spawn(move || {
if cancel.is_cancelled() {
return None;
}
let (actor, activated) = self.ensure_active(&p, None).ok()?;
if activated {
self.changed();
}
let (d, timeout) = (doc.clone(), self.limits.edit_hard);
let started = Instant::now();
let result =
actor.call_edit_cancellable(cancel, CANCEL_GRACE, self.limits.edit_soft, move |rt| {
rt.provide_diagnostics(&d, timeout)
});
let items = self.finish_provider_call(&p, &actor, started, result)?;
(!cancel.is_cancelled()).then(|| (p.id().to_owned(), items))
})
})
.collect();
calls.into_iter().filter_map(|call| call.join().ok().flatten()).collect()
})
}
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
@@ -405,3 +477,41 @@ impl PluginHost {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_snapshots_preserve_order_first_generation_and_language_matching() {
let providers = Arc::new(Providers::default());
let declare = |plugin: &str, kind, language: &str| {
providers.add(plugin.into(), Decl { kinds: vec![kind], languages: vec![language.into()] })
};
let mut declarations = Vec::new();
declarations.push(declare("unrelated", ProviderKind::Hover, "*"));
declarations.push(declare("other-language", ProviderKind::Diagnostics, "rust"));
for i in 0..256 {
let plugin = format!("plugin-{i}");
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "text"));
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "*"));
}
let expected: Vec<_> = (0..256).map(|i| format!("plugin-{i}")).collect();
assert_eq!(providers.plugins_for(ProviderKind::Diagnostics, "text"), expected);
let before = providers.diagnostic_generations("text");
assert_eq!(before.len(), 256);
assert_eq!(before["plugin-0"], 2);
assert_eq!(providers.diagnostic_generations("rust").len(), 257);
assert_eq!(providers.plugins_for(ProviderKind::Hover, "text"), ["unrelated"]);
// Removing the first matching declaration must invalidate its generation,
// even while a second declaration still provides the same language.
drop(declarations.remove(2));
let after = providers.diagnostic_generations("text");
assert_eq!(after["plugin-0"], 3);
assert_ne!(before["plugin-0"], after["plugin-0"]);
drop(declarations);
assert!(providers.diagnostic_generations("text").is_empty());
let _reloaded = declare("plugin-0", ProviderKind::Diagnostics, "text");
assert_ne!(providers.diagnostic_generations("text")["plugin-0"], after["plugin-0"]);
}
}
+115 -2
View File
@@ -69,6 +69,7 @@ pub const SETTINGS: &str = "settings";
pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const MARKER_ICONS: &str = "markerIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
@@ -399,6 +400,13 @@ struct ViewDecl {
icon: Option<String>,
#[serde(default = "right")]
location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
/// Show the selected tree/list item's detail-provider tabs beside it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
detail: bool,
/// A web page of the package (`web/index.html`) instead of content the code renders.
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
@@ -419,6 +427,15 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: ViewDecl = decode(VIEWS, decl)?;
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
if let Some(group) = &d.group {
pkg.manifest.needs_api("contributes.views.group", 3)?;
validate_item_id(group, "view group")?;
}
if let Some(when) = &d.when {
pkg.manifest.needs_api("contributes.views.when", 3)?;
validate_when(when).map_err(|e| format!("contributes.views: {e}"))?;
}
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id));
@@ -429,6 +446,12 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id));
}
}
if d.detail {
pkg.manifest.needs_api(&format!("view {:?}: `detail`", d.id), 3)?;
if d.web.is_some() {
return Err(format!("view {:?}: `detail` needs a tree or list rendered by the plugin (no `web`)", d.id));
}
}
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
}
@@ -540,17 +563,56 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MarkerIconDecl {
id: String,
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
}
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.markerIcons", 3)?;
let d: MarkerIconDecl = decode(MARKER_ICONS, decl)?;
validate_item_id(&d.id, "marker icon id")?;
if d.icon.to_string_lossy().len() > 256 {
return Err("marker icon paths are up to 256 bytes".into());
}
let mut out = UiDecl::new(d.id.clone(), d.id.clone(), serde_json::json!({ "id": d.id, "icon": d.icon }));
for (path, variant) in [(Some(&d.icon), "light"), (d.icon_dark.as_ref(), "dark")] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
out.assets.push((format!("markerIcon/{}/{variant}", d.id), svg));
out.files.push(path.clone());
}
// A changed SVG must reproject even when its declared path stays the same.
let bytes = serde_json::to_vec(&out.assets).map_err(|e| e.to_string())?;
out.json["revision"] = Value::String(blake3::hash(&bytes).to_hex().to_string());
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileTemplateDecl {
id: String,
name: String,
/// Without the dot.
#[serde(default)]
extension: String,
file_name: Option<String>,
variables: Option<Vec<FileTemplateVariable>>,
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
path: PathBuf,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct FileTemplateVariable {
name: String,
prompt: String,
}
const MAX_TEMPLATE: usize = 256 << 10;
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
@@ -559,8 +621,59 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
validate_item_id(&d.id, "file template id")?;
title(FILE_TEMPLATES, &d.name)?;
let what = format!("file template {:?}", d.id);
if d.file_name.is_some() || d.variables.is_some() {
pkg.manifest.needs_api("contributes.fileTemplates: fileName / variables", 3)?;
}
if let Some(name) = &d.file_name {
let stem = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
if name.is_empty()
|| name.len() > 255
|| name.trim() != name
|| name.ends_with('.')
|| matches!(name.as_str(), "." | "..")
|| name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c))
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
&& matches!(stem.as_bytes().get(3), Some(b'1'..=b'9'))
&& stem.len() == 4)
{
return Err(format!("{what}: `fileName` must be a safe single file name"));
}
}
let mut names = std::collections::HashSet::new();
let variables = d.variables.unwrap_or_default();
if variables.len() > 32 {
return Err(format!("{what}: at most 32 variables are allowed"));
}
for variable in &variables {
let name = &variable.name;
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.enumerate()
.all(|(i, c)| c.is_ascii_alphabetic() || c == b'_' || i > 0 && c.is_ascii_digit())
|| matches!(
name.as_str(),
"NAME"
| "FILE_NAME"
| "PACKAGE"
| "PACKAGE_NAME"
| "PROJECT_NAME"
| "USER"
| "DATE"
| "TIME"
| "YEAR"
| "END"
)
|| !names.insert(name)
{
return Err(format!("{what}: invalid, reserved or duplicate variable name {name:?}"));
}
title("file template variable prompt", &variable.prompt)?;
}
let ext = d.extension.trim_start_matches('.');
if ext.is_empty()
if (ext.is_empty() && d.file_name.is_none())
|| ext.len() > 32
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
@@ -570,7 +683,7 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
if body.len() > MAX_TEMPLATE {
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
}
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body });
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body, "fileName": d.file_name, "variables": variables });
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
out.files = vec![d.path.clone()];
Ok(out)
+149
View File
@@ -100,6 +100,23 @@ pub struct WebPage {
pub path: String,
}
/// A web frame's API 1.3 PTY request. The frame never supplies an executable path or cwd.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebPtyCommand {
pub program: String,
pub args: Vec<String>,
pub env: std::collections::BTreeMap<String, String>,
pub cols: u16,
pub rows: u16,
}
pub(super) struct WebPtySession {
id: ide_core::TerminalId,
host: std::sync::Weak<crate::Host>,
window: String,
}
/// A file of a web view.
pub struct WebFile {
pub bytes: Vec<u8>,
@@ -107,6 +124,138 @@ pub struct WebFile {
}
impl PluginHost {
/// Uses the normal PTY backend, without user shell fallback or startup commands.
#[allow(clippy::too_many_arguments)]
pub fn spawn_web_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let _guard = self.lifecycle.lock();
let mut sessions = self.web_ptys.lock();
if self.shared.client_closed(window) {
return Err(IdeError::cancelled());
}
let options = self.web_pty_options(plugin_id, view, window, command)?;
let entries = sessions.entry(plugin_id.to_owned()).or_default();
entries.retain(|s| s.host.upgrade().is_some_and(|h| h.terminals.contains(s.id)));
if entries.len() >= 8 {
return Err(IdeError::invalid("a plugin may own at most 8 PTYs"));
}
let cwd = options.cwd.0.clone();
let info = host.terminals.spawn_program(
options,
host.owner_window(window).to_owned(),
host.window_sink(window),
Box::new(move |data| output(data)),
)?;
if !self
.shared
.workspace()
.is_some_and(|workspace| workspace.project_of(window).as_ref() == Some(&cwd) && workspace.trusted(&cwd))
{
let _ = host.terminals.kill(info.id);
return Err(IdeError::cancelled());
}
entries.push(WebPtySession { id: info.id, host: std::sync::Arc::downgrade(host), window: window.to_owned() });
Ok(info)
}
pub(super) fn forget_web_ptys(&self, window: &str) {
for sessions in self.web_ptys.lock().values_mut() {
sessions.retain(|session| {
if session.window != window {
return true;
}
if let Some(host) = session.host.upgrade() {
let _ = host.terminals.kill(session.id);
}
false
});
}
}
pub(super) fn stop_web_ptys(&self, plugin: &str) {
if let Some(sessions) = self.web_ptys.lock().remove(plugin) {
for session in sessions {
if let Some(host) = session.host.upgrade() {
let _ = host.terminals.kill(session.id);
}
}
}
}
/// Authorizes a PTY immediately before spawning through the existing terminal backend.
pub fn web_pty_options(
&self,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
self.web_page(plugin_id, view)?;
let plugin = self.get(plugin_id)?;
plugin.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
let cap = ide_extension::Capability::Process(command.program.clone());
if !plugin.meta.lock().granted.contains(&cap) {
return Err(IdeError::new(
ide_core::error::ErrorKind::PermissionDenied,
format!("{cap} capability not granted"),
));
}
if command.program.is_empty()
|| command.program.len() > 256
|| command.program.trim() != command.program
|| command.program.contains(['\0', '\r', '\n'])
|| command.args.len() > 256
|| command.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|| !(2..=500).contains(&command.cols)
|| !(1..=500).contains(&command.rows)
{
return Err(IdeError::invalid("invalid PTY program, arguments or dimensions"));
}
if command.env.len() > 64
|| command.env.iter().any(|(k, v)| {
k.is_empty()
|| k.len() > 256
|| k.contains(['=', '\0'])
|| v.len() > 4096
|| v.contains('\0')
|| std::env::vars_os().any(|(inherited, _)| {
if cfg!(windows) {
inherited.to_string_lossy().eq_ignore_ascii_case(k)
} else {
inherited == k.as_str()
}
})
})
{
return Err(IdeError::invalid("PTY environment may only add variables, within the size limits"));
}
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
let cwd = workspace.project_of(window).ok_or_else(|| IdeError::invalid("no project in this window"))?;
if !cwd.is_absolute() || !cwd.is_dir() {
return Err(IdeError::invalid("the project directory no longer exists"));
}
if !workspace.trusted(&cwd) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
}
let program = self.program_for(plugin_id, &command.program)?;
Ok(ide_terminal::TerminalOptions {
cwd: cwd.into(),
shell: Some(program.to_string_lossy().into_owned()),
args: command.args,
env: command.env,
cols: command.cols,
rows: command.rows,
shell_integration: false,
})
}
/// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated).
fn web_dirs(&self, plugin: &str) -> Vec<String> {
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
+9
View File
@@ -27,6 +27,7 @@ pub struct Project {
pub languages: Arc<LanguageRegistry>,
pub settings: Arc<SettingsStore>,
pub documents: Arc<Documents>,
pub(crate) plugin_diagnostics: Arc<crate::plugins::diagnostics::Diagnostics>,
pub history: Arc<LocalHistory>,
/// Undo stack of file move / rename refactorings.
pub moves: FileMoves,
@@ -519,7 +520,11 @@ impl Host {
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root);
documents.add_observer(plugin_diagnostics.clone());
let project = Arc::new(Project {
plugin_diagnostics,
trusted,
root,
sink,
@@ -665,7 +670,11 @@ impl Host {
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &dir);
documents.add_observer(plugin_diagnostics.clone());
let project = Arc::new(Project {
plugin_diagnostics,
trusted: Arc::new(AtomicBool::new(false)),
sink,
languages,
+20 -2
View File
@@ -16,8 +16,14 @@ pub struct TrustDecisions {
pub struct TrustStore {
file: PathBuf,
state: Mutex<IdeResult<TrustDecisions>>,
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
inherited: Mutex<Vec<PathBuf>>,
}
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
/// there: the project folder in the container, trusted without asking again. Never persisted.
pub const TRUSTED_ROOT_ENV: &str = "OPENIDEA_TRUSTED_ROOT";
impl TrustStore {
pub fn load(config: &Path, data: &Path) -> Self {
let file = data.join("project-trust.json");
@@ -34,7 +40,7 @@ impl TrustStore {
}
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state) }
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
@@ -45,7 +51,19 @@ impl TrustStore {
let root = dunce::canonicalize(path)?;
let decisions = self.decisions()?;
Ok(!decisions.restricted.contains(&root)
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
&& (decisions.projects.contains(&root)
|| decisions.locations.iter().any(|p| root.starts_with(p))
|| self.inherited.lock().contains(&root)))
}
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
let root = dunce::canonicalize(path)?;
let mut inherited = self.inherited.lock();
if !inherited.contains(&root) {
inherited.push(root);
}
Ok(())
}
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
+354
View File
@@ -0,0 +1,354 @@
//! Real Wasm provider dispatch and the document-edit path used locally and over RPC.
use ide_core::{CancellationToken, ChangeSet, Diagnostic, IdeEvent, Pos, Range, TextChange};
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, set_editor_providers};
use ide_host::{Host, HostConfig, ops};
use ide_plugin::{Invocation, Limits, ProviderDocument};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const ID: &str = "test.diagnostics";
const SOURCE: &str = "plugin-provider:test.diagnostics";
fn fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics")
}
fn plugins(dir: &Path, limits: Limits) -> Arc<PluginHost> {
let host = Arc::new(PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap());
host.install_from_dir(&fixture(), &Consent::all()).unwrap();
host
}
fn generous() -> Limits {
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() }
}
fn doc(root: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: root.join("a.txt"), language: language.into(), version: 42, text: text.into() }
}
#[test]
fn declared_languages_receive_versioned_text_and_utf16_ranges() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), generous());
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "😀BAD\nBAD"), &CancellationToken::new());
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, ID);
let items = &result[0].1;
assert_eq!(items.len(), 2);
assert_eq!(items[0].range, Range::new(Pos::new(0, 2), Pos::new(0, 5)));
assert_eq!(items[1].range, Range::new(Pos::new(1, 0), Pos::new(1, 3)));
assert_eq!(items[0].message, "BAD in text at version 42");
assert_eq!(items[0].code.as_deref(), Some("bad-word"));
assert_eq!(items[0].server.as_deref(), Some(ID));
assert!(host.provide_diagnostics(&doc(tmp.path(), "rust", "BAD"), &CancellationToken::new()).is_empty());
}
#[test]
fn provider_diagnostics_have_the_same_bounds_and_tags_as_manual_publish() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), generous());
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "limits"), &CancellationToken::new());
let items = &result[0].1;
assert_eq!(items.len(), 5000);
assert_eq!(items[0].message.len(), 4096);
assert_eq!(items[0].source.as_ref().unwrap().len(), 128);
assert_eq!(items[0].code.as_ref().unwrap().len(), 128);
assert!(items[0].unnecessary && items[0].deprecated);
}
#[test]
fn the_export_does_not_need_project_read_permission() {
let tmp = tempfile::tempdir().unwrap();
let package = tmp.path().join("no-capability");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(package.join("plugin.toml"), manifest.replace("[capabilities]\nrequired = [\"project.read\"]", ""))
.unwrap();
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
host.install_from_dir(&package, &Consent::default()).unwrap();
assert_eq!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).len(), 1);
assert!(host.run_command(ID, "publish", Some("/project/a.txt"), Invocation::default()).is_err());
}
#[test]
fn diagnostics_is_an_api_1_3_addition() {
let tmp = tempfile::tempdir().unwrap();
let package = tmp.path().join("old");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(package.join("plugin.toml"), manifest.replace("^1.3", "^1.0")).unwrap();
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
assert!(host.install_from_dir(&package, &Consent::all()).unwrap_err().message.contains("new in plugin API 1.3"));
}
#[test]
fn late_and_cancelled_answers_are_dropped_without_disabling_the_plugin() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() });
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "slow BAD"), &CancellationToken::new()).is_empty());
let cancel = CancellationToken::new();
cancel.cancel();
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1"));
}
#[test]
fn cancelling_a_queued_provider_does_not_interrupt_another_windows_command() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), generous());
host.run_command(ID, "calls", None, Invocation::default()).unwrap();
let waiter = {
let host = host.clone();
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
};
std::thread::sleep(Duration::from_millis(50));
let cancel = CancellationToken::new();
let canceller = {
let cancel = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(30));
cancel.cancel();
})
};
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
canceller.join().unwrap();
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
}
#[test]
fn editor_requests_expire_in_the_queue_without_running_after_a_command() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() });
host.run_command(ID, "calls", None, Invocation::default()).unwrap();
let waiter = {
let host = host.clone();
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
};
std::thread::sleep(Duration::from_millis(50));
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).is_empty());
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
assert_eq!(host.info_of(ID).unwrap().stats.slow_calls, 1);
}
#[test]
fn typing_on_cancels_a_running_provider_before_its_hard_deadline() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), Limits { fuel: u64::MAX, ..generous() });
host.run_command(ID, "calls", None, Invocation::default()).unwrap();
let cancel = CancellationToken::new();
let canceller = {
let cancel = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(30));
cancel.cancel();
})
};
let started = Instant::now();
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "spin BAD"), &cancel).is_empty());
assert!(started.elapsed() < Duration::from_secs(2));
canceller.join().unwrap();
}
// This executable has one edit-path test because editor providers are installed once per process.
type Events = Arc<Mutex<Vec<(String, IdeEvent)>>>;
fn runtime_host(base: &Path, events: Events) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |window| {
let (events, window) = (events.clone(), window.to_owned());
Arc::new(move |event: IdeEvent| events.lock().push((window.clone(), event)))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn latest(events: &Events, window: &str, source: &str) -> Option<Vec<Diagnostic>> {
events.lock().iter().rev().find_map(|(w, e)| match e {
IdeEvent::Diagnostics { source: s, items, .. } if w == window && s == source => Some(items.clone()),
_ => None,
})
}
async fn wait_for(events: &Events, window: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
wait_for_source(events, window, SOURCE, predicate).await;
}
async fn wait_for_source(events: &Events, window: &str, source: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
let start = Instant::now();
loop {
if latest(events, window, source).is_some_and(|items| predicate(&items)) {
return;
}
assert!(start.elapsed() < Duration::from_secs(5), "missing diagnostics for {window}");
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn replace(host: &Host, window: &str, info: &ide_workspace::DocumentInfo, text: &str) -> u32 {
let project = host.project(window).unwrap();
let snapshot = project.documents.snapshot(info.id).unwrap();
let len = snapshot.text.encode_utf16().count() as u32;
ops::doc::edit(
host,
window,
info.id,
snapshot.version,
ChangeSet { changes: vec![TextChange { from: 0, to: len, insert: text.into() }] },
)
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window_lifetimes() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let path = root.join("a.txt");
std::fs::write(&path, "clean").unwrap();
let events: Events = Arc::default();
let host = runtime_host(&root.join("app"), events.clone());
let plugins = plugins(&root, generous());
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
set_editor_providers(Some(&plugins));
let left = host.open_light("left", &path).unwrap();
host.open_light("right", &path).unwrap();
let a = ops::doc::open(&host, "left", path.clone()).await.unwrap();
let b = ops::doc::open(&host, "right", path.clone()).await.unwrap();
let second_tab = ops::doc::open(&host, "right", path.clone()).await.unwrap();
assert_eq!(b.id, second_tab.id);
wait_for(&events, "left", |items| items.is_empty()).await;
wait_for(&events, "right", |items| items.is_empty()).await;
let calls: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
events.lock().clear();
replace(&host, "left", &a, "BAD");
tokio::time::sleep(Duration::from_millis(100)).await;
let version = replace(&host, "left", &a, "😀BAD");
tokio::time::sleep(Duration::from_millis(150)).await;
assert!(latest(&events, "left", SOURCE).is_none(), "300ms after the latest edit");
wait_for(&events, "left", |items| !items.is_empty()).await;
assert_eq!(
latest(&events, "left", SOURCE).unwrap()[0].message,
format!("BAD in {} at version {version}", a.language)
);
assert!(latest(&events, "right", SOURCE).is_none(), "other window has its own buffer");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "clean", "no save required");
let after: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
assert_eq!(after, calls + 1, "the edit burst was coalesced");
plugins.run_command(ID, "publish", path.to_str(), Invocation::default()).unwrap();
let manual_window = events.lock().iter().find_map(|(window, event)| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "plugin:test.diagnostics" && !items.is_empty()).then(|| window.clone())).unwrap();
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
replace(&host, "left", &a, "clean again");
wait_for(&events, "left", |items| items.is_empty()).await;
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
events.lock().clear();
replace(&host, "left", &a, "slow BAD");
tokio::time::sleep(Duration::from_millis(350)).await;
replace(&host, "left", &a, "latest clean");
wait_for(&events, "left", |items| items.is_empty()).await;
assert!(
!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())),
"superseded in-flight answers never publish"
);
replace(&host, "right", &b, "BAD right");
wait_for(&events, "right", |items| !items.is_empty()).await;
ops::doc::close(&host, "right", b.id).unwrap();
assert!(!latest(&events, "right", SOURCE).unwrap().is_empty(), "another pane still owns this document");
ops::doc::close(&host, "right", second_tab.id).unwrap();
wait_for(&events, "right", |items| items.is_empty()).await;
replace(&host, "left", &a, "BAD left");
wait_for(&events, "left", |items| !items.is_empty()).await;
plugins.set_enabled(ID, false).unwrap();
wait_for(&events, "left", |items| items.is_empty()).await;
plugins.set_enabled(ID, true).unwrap();
events.lock().clear();
replace(&host, "left", &a, "slow BAD closing");
tokio::time::sleep(Duration::from_millis(350)).await;
host.close_project("left");
// Keep the old Project alive deliberately: window reuse must still cancel it.
assert_eq!(left.documents.snapshot(a.id).unwrap().text, "slow BAD closing");
host.open_light("left", &path).unwrap();
let reopened = ops::doc::open(&host, "left", path.clone()).await.unwrap();
wait_for(&events, "left", |items| items.is_empty()).await;
assert!(!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())));
// Project plugins receive only snapshots from the project that owns their declaration.
let project_package = root.join(ide_host::plugins::DEV_PLUGINS_DIR).join("diagnostics");
std::fs::create_dir_all(project_package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), project_package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(project_package.join("plugin.toml"), manifest.replace(ID, "project.diagnostics")).unwrap();
plugins.set_project_policy(Arc::new(|_| true));
plugins.sync_project(&root);
plugins.grant("project.diagnostics", &Consent::all()).unwrap();
replace(&host, "left", &reopened, "BAD in project");
wait_for(&events, "left", |items| !items.is_empty()).await;
let project_source = "plugin-provider:project.diagnostics";
wait_for_source(&events, "left", project_source, |items| !items.is_empty()).await;
let elsewhere = root.join("other");
std::fs::create_dir_all(&elsewhere).unwrap();
let other_path = elsewhere.join("other.txt");
std::fs::write(&other_path, "BAD outside").unwrap();
host.open_light("elsewhere", &other_path).unwrap();
ops::doc::open(&host, "elsewhere", other_path).await.unwrap();
wait_for(&events, "elsewhere", |items| !items.is_empty()).await;
assert!(latest(&events, "elsewhere", project_source).is_none());
plugins.restrict_project(&root);
assert!(latest(&events, "left", project_source).unwrap().is_empty());
// A completed answer may still be waiting for a sibling when its plugin crashes.
let sibling = root.join("slow-sibling");
std::fs::create_dir_all(sibling.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), sibling.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(
sibling.join("plugin.toml"),
manifest.replace(ID, "test.slow").replace("[\"text\", \"plaintext\"]", "[\"plaintext\"]"),
)
.unwrap();
plugins.install_from_dir(&sibling, &Consent::all()).unwrap();
plugins.run_command("test.slow", "calls", None, Invocation::default()).unwrap();
let calls = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap();
events.lock().clear();
replace(&host, "left", &reopened, "BAD completed before crash");
tokio::time::sleep(Duration::from_millis(200)).await;
let waiter = {
let plugins = plugins.clone();
std::thread::spawn(move || plugins.run_command("test.slow", "wait", None, Invocation::default()))
};
let started = Instant::now();
while plugins.run_command(ID, "calls", None, Invocation::default()).unwrap() == calls {
assert!(started.elapsed() < Duration::from_secs(5));
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
"the sibling still holds the result: {:?}",
latest(&events, "left", SOURCE)
);
assert!(plugins.provide_diagnostics(&doc(&root, "text", "panic"), &CancellationToken::new()).is_empty());
waiter.join().unwrap().unwrap();
wait_for_source(&events, "left", "plugin-provider:test.slow", |items| !items.is_empty()).await;
assert!(
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
"a crash invalidates completed answers"
);
ops::doc::close(&host, "left", reopened.id).unwrap();
host.shutdown().await;
set_editor_providers(None);
}
+232
View File
@@ -0,0 +1,232 @@
//! The synchronous workspace import uses the real project index, also from a provider.
use ide_core::CancellationToken;
use ide_core::{NullSink, Pos, Range};
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, PluginWorkspace};
use ide_host::{Host, HostConfig};
use ide_plugin::{Invocation, Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
const WAIT: Duration = Duration::from_secs(30);
struct Fixture {
_tmp: tempfile::TempDir,
_runtime: tokio::runtime::Runtime,
host: Arc<Host>,
plugins: PluginHost,
root: PathBuf,
other: PathBuf,
}
impl Fixture {
fn new(capability: bool) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let (root, other) = (base.join("project"), base.join("other"));
for dir in [&root, &other] {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(root.join("Order.php"), "<?php class OrderController { public function item() {} }\n").unwrap();
std::fs::write(root.join("Main.java"), "class OrderService { void serve() {} }\n").unwrap();
std::fs::write(other.join("Order.php"), "<?php class OrderElsewhere {}\n").unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let syntax = OnceLock::new();
syntax.set(Arc::new(ide_language::Syntax::new())).ok().unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::new(syntax),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
// Restricted projects need no LSP/process startup for index queries.
{
let _guard = runtime.enter();
for (window, dir) in [("main", &root), ("other", &other)] {
let project = host.open_project(window, dir).unwrap();
assert!(project.index.wait_idle(WAIT));
}
}
let plugins = PluginHost::new(
base.join("plugins"),
Arc::new(|_: Option<&str>, _| {}),
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() },
)
.unwrap();
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
plugins.set_project_policy(Arc::new(|_| true));
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/symbols");
let package = base.join("fixture");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(source.join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(source.join("plugin.toml")).unwrap();
let manifest =
if capability { manifest } else { manifest.replace("required = [\"project.read\"]", "required = []") };
std::fs::write(package.join("plugin.toml"), manifest).unwrap();
plugins.install_from_dir(&package, &Consent::all()).unwrap();
Self { _tmp: tmp, _runtime: runtime, host, plugins, root, other }
}
fn invoke(&self, command: &str, root: &Path, query: &str) -> ide_core::IdeResult<Option<String>> {
self.plugins.run_command(
"test.symbols",
command,
Some(query),
Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() },
)
}
fn search(&self, query: &str) -> String {
self.invoke("symbols", &self.root, query).unwrap().unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
self.host.close_project("other");
}
}
#[test]
fn symbols_reach_commands_and_providers_without_a_language_server() {
let f = Fixture::new(true);
let all = f.search("Order|10");
assert!(all.contains("OrderController\tclass\t"), "{all}");
assert!(all.contains("OrderService\tclass\t"), "{all}");
assert!(!all.contains("OrderElsewhere"));
assert_eq!(f.search("OCo|1").lines().count(), 1);
assert!(f.search("OrderController.item|1").starts_with("item\tmethod\tOrderController\t"));
assert!(f.search("|10").is_empty());
assert!(f.search("Order|0").is_empty());
assert!(f.search("missing|10").is_empty());
assert!(f.search("Order|4294967295").contains("OrderController"));
assert!(
f.invoke("symbols", &f.root, &format!("{}|1", "é".repeat(129)))
.unwrap_err()
.message
.contains("256 UTF-8 bytes")
);
let other = f.invoke("symbols", &f.other, "Order|10").unwrap().unwrap();
assert!(other.contains("OrderElsewhere") && !other.contains("OrderController"));
for language in ["php", "java"] {
let targets = f.plugins.provide_definition(
&ProviderDocument {
path: f.root.join(if language == "php" { "Usage.php" } else { "Usage.java" }),
language: language.into(),
version: 1,
text: "OCo".into(),
},
Pos::new(0, 3),
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].path, f.root.join("Order.php"));
let indexed = HostWorkspace(Arc::downgrade(&f.host))
.symbols(&f.root, "OCo", 1, &CancellationToken::new(), Instant::now() + WAIT)
.unwrap();
assert_eq!(targets[0].range, indexed[0].location.range);
}
}
#[test]
fn reindex_and_closed_project_replace_results_and_invalidate_retained_handles() {
let f = Fixture::new(true);
f.invoke("remember", &f.root, "").unwrap();
let p = f.host.project("main").unwrap();
let path = f.root.join("Order.php");
std::fs::write(&path, "<?php /*😀*/ class UpdatedController {}\n").unwrap();
p.index.update(std::slice::from_ref(&path), &[]);
assert!(p.index.wait_idle(WAIT));
assert!(!f.search("Order|10").contains("OrderController"));
let symbols = HostWorkspace(Arc::downgrade(&f.host))
.symbols(&f.root, "UpdatedController", 1, &CancellationToken::new(), Instant::now() + WAIT)
.unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].location.range, Range::new(Pos::new(0, 19), Pos::new(0, 36)));
assert!(f.search("Updated|10").contains("0:19-0:36"));
std::fs::remove_file(&path).unwrap();
p.index.update(&[], &[path]);
assert!(p.index.wait_idle(WAIT));
assert!(f.search("Updated|10").is_empty());
f.host.close_project("main");
let error = f.invoke("remembered", &f.root, "Order|10").unwrap_err();
assert!(error.message.contains("no longer open"), "{error}");
}
#[test]
fn large_limits_are_capped_before_searching_the_index() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
let path = f.root.join("Bulk.java");
let text = (0..1005).map(|i| format!("class BulkSymbol{i} {{}}\n")).collect::<String>();
std::fs::write(&path, text).unwrap();
project.index.update(&[path], &[]);
assert!(project.index.wait_idle(WAIT));
assert_eq!(f.search("BulkSymbol|4294967295").lines().count(), 1000);
}
#[test]
fn symbol_queries_require_project_read_even_with_an_invocation_project() {
let f = Fixture::new(false);
let error = f.invoke("symbols", &f.root, "Order|10").unwrap_err();
assert!(error.message.contains("project.read"), "{error}");
assert!(
f.plugins
.provide_definition(
&ProviderDocument {
path: f.root.join("Usage.php"),
language: "php".into(),
version: 1,
text: "OCo".into(),
},
Pos::new(0, 3)
)
.is_empty()
);
}
#[test]
fn symbol_search_excludes_external_content_roots_before_ranking() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
std::fs::write(f.other.join("Exact.php"), "<?php class Order {}\n").unwrap();
project.index.set_scope(vec![f.other.clone()], Vec::new());
assert!(project.index.wait_idle(WAIT));
assert_eq!(project.index.find_symbols("OrderElsewhere", ide_index::SymbolFilter::All, 1).len(), 1);
assert!(f.search("OrderElsewhere|10").is_empty());
// A better external match must not displace a readable fuzzy match.
let result = f.search("Order|1");
assert!(result.contains("OrderController") || result.contains("OrderService"), "{result}");
}
#[test]
fn native_symbol_search_honours_cancellation_and_deadlines() {
let f = Fixture::new(true);
let workspace = HostWorkspace(Arc::downgrade(&f.host));
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
workspace.symbols(&f.root, "Order", 10, &cancel, Instant::now() + WAIT),
Err(ide_plugin::HostError::Cancelled)
);
let error = workspace.symbols(&f.root, "Order", 10, &CancellationToken::new(), Instant::now()).unwrap_err();
assert!(error.to_string().contains("timed out"), "{error}");
}
#[cfg(unix)]
#[test]
fn symbol_search_excludes_updates_through_escaping_symlink_ancestors() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
std::fs::write(f.other.join("Secret.php"), "<?php class SecretSymbol {}\n").unwrap();
let dir = f.root.join("linked");
std::os::unix::fs::symlink(&f.other, &dir).unwrap();
project.index.update(&[dir.join("Secret.php")], &[]);
assert!(project.index.wait_idle(WAIT));
assert!(f.search("Secret|1").is_empty());
}
+900 -5
View File
@@ -22,6 +22,7 @@ struct TestWorkspace {
other_roots: Mutex<Vec<PathBuf>>,
documents: Arc<ide_workspace::Documents>,
trusted: bool,
project_queries_left: Mutex<Option<usize>>,
/// (producer, path) → published diagnostics.
diagnostics: Mutex<std::collections::BTreeMap<(String, PathBuf), Vec<ide_core::Diagnostic>>>,
/// plugin → its settings as the user set them.
@@ -39,6 +40,7 @@ impl TestWorkspace {
other_roots: Mutex::default(),
documents: Arc::new(documents),
trusted: true,
project_queries_left: Mutex::new(None),
diagnostics: Mutex::default(),
settings: Mutex::default(),
})
@@ -93,6 +95,12 @@ impl PluginWorkspace for TestWorkspace {
self.trusted
}
fn project_of(&self, _window: &str) -> Option<PathBuf> {
if let Some(left) = self.project_queries_left.lock().as_mut() {
if *left == 0 {
return None;
}
*left -= 1;
}
Some(self.root.clone())
}
fn publish_diagnostics(
@@ -551,6 +559,103 @@ fn fs_write_reaches_only_the_granted_paths() {
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
/// A program kept running behind a handle: the plugin writes its stdin, closes
/// it, and waits for the exit status and what the program printed. A one-off run
/// passes the environment it names.
#[test]
fn a_plugin_keeps_a_process_running_behind_a_handle() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
f.host.set_capability("misbehave", "process:git", false).unwrap();
assert!(f.run("misbehave", "envrun", Some("legacy")).unwrap_err().contains("process:git"));
for command in ["envrun", "envstart", "child"] {
let err = f.run("misbehave", command, None).unwrap_err();
assert!(err.contains("process:git"), "{command}: {err}");
}
// The fixture declares `process:git` as optional: it needs a program that
// reads stdin and reports its environment. Machines running these tests have
// git; without one, there is nothing to run and nothing to check.
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
let out = f.run("misbehave", "child", None).expect("the handle works").expect("a result");
let (hash, status) = out.split_once(' ').unwrap_or_else(|| panic!("{out}"));
assert_eq!(hash.len(), 40, "a sha1 of what was written: {out}");
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "{out}");
assert_eq!(status, "Some(0)", "it ended by itself: {out}");
assert!(f.run("misbehave", "envrun", Some("legacy")).unwrap().unwrap().starts_with("git version"));
let ident = f.run("misbehave", "envrun", None).expect("a run with extra environment").expect("a result");
assert_eq!(ident.trim(), "added by the plugin");
assert_eq!(f.run("misbehave", "envstart", None).unwrap().unwrap().trim(), "added by the plugin");
for command in ["envdeny-run", "envdeny-start"] {
let inherited = std::env::vars().find(|(key, _)| key != "PATH" && !key.contains('=')).unwrap().0;
for key in ["PATH", inherited.as_str(), "", "BAD=KEY", "BAD\0KEY"] {
let err = f.run("misbehave", command, Some(key)).unwrap_err();
assert!(err.contains("environment variable"), "{command} {key:?}: {err}");
}
}
// Dropping a resource returns the slot; the limit is simultaneous handles.
for _ in 0..10 {
f.run("misbehave", "childkeep", None).unwrap();
assert_eq!(f.run("misbehave", "childpoll", None).unwrap().as_deref(), Some("None"));
f.run("misbehave", "childdrop", None).unwrap();
}
for _ in 0..8 {
f.run("misbehave", "childkeep", None).unwrap();
}
assert!(f.run("misbehave", "childkeep", None).unwrap_err().contains("at most 8"));
f.run("misbehave", "childdrop", None).unwrap();
f.run("misbehave", "childkeep", None).unwrap();
f.host.forget_client("main");
assert!(f.run("misbehave", "childpoll", None).unwrap_err().contains("no process"));
assert!(f.run("misbehave", "childkeep", None).is_err(), "late starts are cancelled");
f.host.reopen_client("main");
f.run("misbehave", "childdrop", None).unwrap();
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[cfg(unix)]
#[test]
fn plugin_processes_end_with_their_owner() {
for mode in ["disable", "unload", "shutdown", "reclaim", "trap", "window"] {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
f.install(&misbehave_pkg());
f.host.set_capability("misbehave", "process:git", true).unwrap();
let marker = format!("--path={}/process-{mode}", f.project.display());
let alive = || {
let ps = std::process::Command::new("ps").args(["-axo", "command="]).output().unwrap();
assert!(ps.status.success());
String::from_utf8_lossy(&ps.stdout).lines().any(|line| line.contains(&marker))
};
f.run("misbehave", "childkeep", marker.strip_prefix("--path=")).unwrap();
assert!(alive(), "{mode}: the process started");
match mode {
"disable" => f.host.set_enabled("misbehave", false).unwrap(),
"unload" => f.host.uninstall("misbehave").unwrap(),
"shutdown" => f.host.shutdown(),
"reclaim" => {
std::thread::sleep(Duration::from_millis(75));
f.host.tick();
assert_eq!(f.host.instances(), 0);
}
"trap" => {
assert!(f.run("misbehave", "panic", None).is_err());
}
"window" => f.host.forget_client("main"),
_ => unreachable!(),
}
let deadline = Instant::now() + Duration::from_secs(5);
while alive() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!alive(), "{mode}: the process must end with its owner");
}
}
/// A missing capability is an error the plugin can handle, not a trap.
#[test]
fn ungranted_capabilities_are_denied_not_trapped() {
@@ -827,7 +932,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(), 14, "contributions stay");
assert_eq!(f.command_titles("misbehave").len(), 23, "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);
@@ -1092,6 +1197,25 @@ fn untrusted_or_unconfirmed_project_plugins_never_run() {
assert!(f.host.reload("misbehave").is_err());
assert!(f.host.set_enabled("misbehave", true).is_err());
assert!(f.run("misbehave", "echo", None).is_err());
let doc = ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: "prompts".into(),
};
let action = ide_plugin::CodeAction {
title: "Ask input".into(),
kind: None,
preferred: false,
edits: Vec::new(),
operations: Vec::new(),
open: None,
data: Some("input".into()),
file_action: false,
};
let error = f.host.resolve_code_action("misbehave", &doc, action, f.inv()).unwrap_err();
assert!(error.message.contains("Restricted Mode"), "{}", error.message);
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
@@ -1831,6 +1955,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
git(&f.project, &["init", "-b", "main"]);
f.install(&official("git-toolbox"));
let untrusted = Arc::new(TestWorkspace {
project_queries_left: Mutex::new(None),
root: f.project.clone(),
other_roots: Mutex::default(),
documents: f.workspace.documents.clone(),
@@ -2252,6 +2377,86 @@ fn view_item_menus_name_a_declared_view() {
assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message);
}
#[test]
fn conditional_and_grouped_views_need_api_1_3() {
for field in ["group = \"spring\"", "when = \"workspaceContains:pom.xml || plugin.hello.ready\""] {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let old =
hello_variant(tmp.path(), |m| m.replace("location = \"right\"", &format!("location = \"right\"\n{field}")));
let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{err:?}");
}
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |m| {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"").replace(
"location = \"right\"",
"location = \"right\"\ngroup = \"spring\"\nwhen = \"workspaceContains:pom.xml || plugin.hello.ready\"",
)
});
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Inactive, "declaring views must not activate code");
let view = f.host.ui_items("views").remove(0);
assert_eq!(view.decl["group"], "spring");
assert_eq!(view.decl["when"], "workspaceContains:pom.xml || plugin.hello.ready");
f.host.set_enabled("hello", false).unwrap();
assert!(f.host.ui_items("views").is_empty());
}
#[test]
fn grouped_views_fixture_renders_and_scopes_context_and_reveal() {
let f = fixture(Limits::default());
let info = f.install(&sample("grouped-views"));
assert_eq!(info.status, PluginStatus::Inactive, "views stay lazy");
assert!(info.error.is_none(), "{:?}", info.error);
let views = f.host.ui_items("views");
assert_eq!(views.len(), 3);
assert_eq!(views[0].decl["group"], "demo");
assert_eq!(views[1].decl["when"], "workspaceContains:grouped-views.txt");
f.take_events();
f.run("grouped-views", "show", None).unwrap();
assert!(f.take_events().iter().any(|(client, event)| matches!(
event,
PluginEvent::ContextChanged { plugin_id, key, value }
if client.as_deref() == Some("main") && plugin_id == "grouped-views"
&& key == "ready" && value.as_deref() == Some("true")
)));
assert!(f.host.context_keys(Some("other")).is_empty());
for view in ["overview", "files", "legacy"] {
let rendered = f.host.render_view("grouped-views", view, None, f.inv()).unwrap();
assert!(matches!(rendered.body, ide_plugin::ViewBody::Message(_)));
f.run("grouped-views", "reveal", Some(view)).unwrap();
assert!(f.take_events().iter().any(|(client, event)| matches!(
event,
PluginEvent::RevealView { plugin_id, view: target }
if client.as_deref() == Some("main") && plugin_id == "grouped-views" && target == view
)));
}
assert!(f.run("grouped-views", "reveal", Some("plugin.hello.greetings")).is_err());
f.host.set_enabled("grouped-views", false).unwrap();
assert!(f.host.ui_items("views").is_empty());
assert!(f.host.context_keys(Some("main")).is_empty());
}
#[test]
fn view_conditions_and_group_ids_are_validated() {
for (field, message) in [
("group = \"\"", "invalid view group"),
("group = \"../other\"", "invalid view group"),
("when = \"ready && (\"", "when"),
] {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |m| {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
.replace("location = \"right\"", &format!("location = \"right\"\n{field}"))
});
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{field}: {err:?}");
}
}
/// The sample plugin makes a tool window with a tree view.
/// Its menus, shortcut and status bar item are declarations, listed before
/// any code runs; the tree is rendered by the code, which loads on first show.
@@ -2811,6 +3016,81 @@ path = "templates/new.draw"
assert!(f.host.web_page("web-demo", "draw").is_err());
}
#[test]
fn file_templates_fixture_installs_without_write_capabilities() {
let f = fixture(Limits::default());
let info = f.install(&sample("file-templates"));
assert!(info.capabilities.is_empty());
let templates = f.host.ui_items("fileTemplates");
assert_eq!(templates.len(), 4);
let template = |id: &str| templates.iter().find(|t| t.decl["id"] == id).unwrap();
assert_eq!(template("dockerignore").decl["fileName"], ".dockerignore");
assert_eq!(template("attributes").decl["variables"].as_array().unwrap().len(), 2);
assert!(template("repository").decl["body"].as_str().unwrap().contains("$PACKAGE$"));
assert!(f.installed("file-templates").join("templates/repository.txt").is_file());
assert_eq!(f.run("file-templates", "ping", None).unwrap().as_deref(), Some("File templates ready"));
f.host.set_enabled("file-templates", false).unwrap();
assert!(f.host.ui_items("fileTemplates").is_empty());
}
#[test]
fn file_templates_api_13_names_and_variables() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("template-demo");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(pkg.join("body.txt"), "$ENTITY$ $PACKAGE$\n").unwrap();
let install = |api: &str, fields: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "template-demo"
name = "Templates"
version = "1.0.0"
engines.openidea = "{api}"
[[contributes.fileTemplates]]
id = "template"
name = "Template"
path = "body.txt"
{fields}
"#
),
)
.unwrap();
f.host.install_from_dir(&pkg, &Consent::all())
};
for fields in [r#"fileName = ".dockerignore""#, "extension = \"java\"\nvariables = []"] {
let err = install("^1.2", fields).unwrap_err().message;
assert!(err.contains("1.3"), "{err}");
}
for name in ["", ".", "..", "../escape", "a/b", r"a\b", "/absolute", "C:evil", "a?", " a", "a.", "NUL.txt", "COM1"]
{
let fields = format!("fileName = {}", serde_json::to_string(name).unwrap());
assert!(install("^1.3", &fields).is_err(), "accepted {name:?}");
}
for vars in [
r#"[{ name = "", prompt = "Entity" }]"#,
r#"[{ name = "1bad", prompt = "Entity" }]"#,
r#"[{ name = "bad-name", prompt = "Entity" }]"#,
r#"[{ name = "PACKAGE", prompt = "Entity" }]"#,
r#"[{ name = "ENTITY", prompt = "" }]"#,
r#"[{ name = "ENTITY", prompt = "Entity" }, { name = "ENTITY", prompt = "Again" }]"#,
] {
assert!(install("^1.3", &format!("extension = \"java\"\nvariables = {vars}")).is_err(), "accepted {vars}");
}
install(
"^1.3",
r#"fileName = ".dockerignore"
variables = [{ name = "ENTITY", prompt = "Entity class" }]"#,
)
.unwrap();
let items = f.host.ui_items("fileTemplates");
assert_eq!(items[0].decl["fileName"], ".dockerignore");
assert_eq!(items[0].decl["extension"], "");
assert_eq!(items[0].decl["variables"][0]["prompt"], "Entity class");
assert_eq!(items[0].decl["body"], "$ENTITY$ $PACKAGE$\n");
}
/// A view needs a page or code to render it; a page's directory can't be the package itself.
#[test]
fn web_view_declarations_are_checked() {
@@ -3126,10 +3406,10 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
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();
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone(), f.inv()).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());
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign, f.inv()).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();
@@ -3148,7 +3428,7 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
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();
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later, f.inv()).unwrap();
assert_eq!(
resolved.operations,
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
@@ -3168,6 +3448,185 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
}
fn interactive_actions_fixture() -> (Fixture, ide_plugin::ProviderDocument) {
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
let pkg = f._tmp.path().join("interactive-actions");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml"))
.unwrap()
.replace("required = [\"project.read\"]", "required = [\"project.read\", \"clipboard\"]");
std::fs::write(pkg.join("plugin.toml"), format!(
"{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"
)).unwrap();
f.install(&pkg);
f.host.run_command("misbehave", "echo", None, f.inv()).unwrap();
let doc = ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: "prompts".into(),
};
(f, doc)
}
fn action_prompt(f: &Fixture) -> (String, ide_plugin::Prompt) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some((client, id, prompt)) = f.take_events().into_iter().find_map(|(client, event)| match event {
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
_ => None,
}) {
assert_eq!(client.as_deref(), Some("editor-window"));
return (id, prompt);
}
assert!(Instant::now() < deadline, "code-action resolution never asked");
std::thread::sleep(Duration::from_millis(10));
}
}
fn action_invocation() -> Invocation {
Invocation { client: Some("editor-window".into()), ..Default::default() }
}
#[test]
fn code_action_resolution_prompts_in_the_editor_window_under_the_command_budget() {
use ide_plugin::Prompt;
let (f, doc) = interactive_actions_fixture();
let actions = f.host.provide_code_actions(&doc, ide_core::Range::default());
assert_eq!(actions.len(), 10);
for (kind, answer, expected) in [
("pick", vec!["b"], "b"),
("pick-many", vec!["a", "b"], "a,b"),
("input", vec!["Service😀"], "Service😀"),
("confirm", vec![], "confirmed"),
] {
let action = actions.iter().find(|(_, a)| a.data.as_deref() == Some(kind)).unwrap().1.clone();
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, prompt) = action_prompt(&f);
assert!(match kind {
"pick" => matches!(prompt, Prompt::QuickPick { multiple: false, .. }),
"pick-many" => matches!(prompt, Prompt::QuickPick { multiple: true, .. }),
"input" => matches!(prompt, Prompt::Input { multiline: false, .. }),
_ => matches!(prompt, Prompt::Confirm { .. }),
});
// More than the command budget; waiting for the user is excluded.
std::thread::sleep(Duration::from_millis(650));
assert!(f.host.answer_prompt(&id, Some(answer.iter().map(|s| (*s).into()).collect())));
let resolved = running.join().unwrap().unwrap();
assert_eq!(resolved.edits[0].1[0].new_text, expected);
});
}
for kind in ["input-multiline", "reveal", "open-url", "clipboard", "pick-directory", "open-project"] {
let action = actions.iter().find(|(_, a)| a.data.as_deref() == Some(kind)).unwrap().1.clone();
let err = std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let mut prompted = false;
while !running.is_finished() {
for (_, event) in f.take_events() {
if let PluginEvent::PromptRequested { prompt_id, .. } = event {
prompted = true;
f.host.answer_prompt(&prompt_id, None);
}
}
std::thread::sleep(Duration::from_millis(10));
}
let result = running.join().unwrap();
assert!(!prompted, "{kind} must remain restricted to commands and events");
result.unwrap_err()
});
assert_eq!(err.kind, ErrorKind::InvalidInput, "{kind}: {}", err.message);
}
let doc = ide_plugin::ProviderDocument { text: "prompt-provider".into(), ..doc };
assert!(eventually(|| !f.host.provide_completion(&doc, ide_core::Pos::new(0, 0), None).is_empty()));
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
}
#[test]
fn code_action_prompts_dismiss_cancel_and_close_without_edits_or_hanging() {
for (how, kind) in [
("dismiss", "pick"),
("dismiss", "pick-many"),
("dismiss", "input"),
("dismiss", "confirm"),
("disable", "pick"),
("close", "pick"),
("close-reopen", "pick"),
("no-window", "pick"),
] {
let (f, doc) = interactive_actions_fixture();
let action = f
.host
.provide_code_actions(&doc, ide_core::Range::default())
.into_iter()
.find(|(_, action)| action.data.as_deref() == Some(kind))
.unwrap()
.1;
if how == "no-window" {
assert!(f.host.resolve_code_action("misbehave", &doc, action, Invocation::default()).is_err());
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
continue;
}
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
let started = Instant::now();
match how {
"dismiss" => {
assert!(f.host.answer_prompt(&id, None));
}
"disable" => {
f.host.set_enabled("misbehave", false).unwrap();
}
"close" => f.host.forget_client("editor-window"),
"close-reopen" => {
f.host.forget_client("editor-window");
f.host.reopen_client("editor-window");
}
_ => unreachable!(),
}
assert!(running.join().unwrap().is_err());
assert!(started.elapsed() < Duration::from_secs(5));
assert!(!f.host.answer_prompt(&id, Some(vec!["late".into()])));
});
assert!(!f.project.join("a.txt").exists(), "resolution must only return edits");
if how == "close-reopen" {
let action = f.host.provide_code_actions(&doc, ide_core::Range::default()).remove(0).1;
std::thread::scope(|scope| {
let running =
scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
assert!(f.host.answer_prompt(&id, Some(vec!["b".into()])));
assert_eq!(running.join().unwrap().unwrap().edits[0].1[0].new_text, "b");
});
}
}
}
#[test]
fn file_action_resolution_can_ask_before_returning_resource_operations() {
let (f, doc) = interactive_actions_fixture();
let doc = ide_plugin::ProviderDocument { text: "prompt-files".into(), ..doc };
let action = f.host.provide_code_actions(&doc, ide_core::Range::default()).remove(0).1;
assert!(action.file_action && action.operations.is_empty());
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
assert!(f.host.answer_prompt(&id, Some(vec!["Service".into()])));
let resolved = running.join().unwrap().unwrap();
assert_eq!(
resolved.operations,
vec![ide_plugin::FileOperation::Create {
path: f.project.join("Service.txt"),
text: "Service".into(),
overwrite: false,
}]
);
assert_eq!(resolved.open, Some(f.project.join("Service.txt")));
assert!(!f.project.join("Service.txt").exists(), "resolution does not apply the edit");
});
}
/// `hello` as a services view, which needs API 1.3.
fn services_variant(text: &str) -> String {
text.replace("location = \"right\"", "location = \"services\"")
@@ -3226,7 +3685,80 @@ fn services_views_have_a_detail_pane() {
let f = fixture(Limits::default());
f.install(&sample("hello"));
let err = f.host.service_detail("hello", "greetings", "x", f.inv()).unwrap_err();
assert!(err.message.contains("declares no services view"), "a right view has no pane: {err:?}");
assert!(err.message.contains("declares no detail view"), "a right view has no pane: {err:?}");
}
#[test]
fn detail_views_work_in_every_location_and_require_api_1_3_code() {
for location in ["left", "right", "bottom", "services"] {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |t| {
t.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
.replace("location = \"right\"", &format!("location = {location:?}\ndetail = true"))
});
assert!(f.install(&pkg).error.is_none());
assert_eq!(f.host.ui_items("views")[0].decl["detail"], true);
let tabs = f.host.service_detail("hello", "greetings", "item:😀", f.inv()).unwrap();
assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: item:😀".into()));
assert!(f.host.service_detail("hello", "missing", "x", f.inv()).is_err());
}
for (api, web, message) in
[("^1.0", false, "new in plugin API 1.3"), ("^1.3", true, "`detail` needs a tree or list")]
{
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |t| {
t.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = {api:?}")).replace(
"location = \"right\"",
if web {
"location = \"right\"\ndetail = true\nweb = \"web/index.html\""
} else {
"location = \"right\"\ndetail = true"
},
)
});
if web {
std::fs::create_dir_all(pkg.join("web")).unwrap();
std::fs::write(pkg.join("web/index.html"), "<p>x</p>").unwrap();
}
let error = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(error.message.contains(message), "{error:?}");
}
}
#[test]
fn detail_fixture_tracks_selection_and_refresh_for_tree_list_and_services() {
let f = fixture(Limits::default());
assert!(f.install(&sample("detail-views")).error.is_none());
for view in ["tree", "list", "services"] {
assert!(matches!(
f.host.render_view("test.detail-views", view, None, f.inv()).unwrap().body,
ide_plugin::ViewBody::Tree(_) | ide_plugin::ViewBody::List(_)
));
f.host
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Select { key: "beta".into() }, f.inv())
.unwrap();
let tabs = f.host.service_detail("test.detail-views", view, "beta", f.inv()).unwrap();
let ide_plugin::ServicePane::Text(text) = &tabs[0].body else { panic!("text detail") };
assert!(text.starts_with(&format!("{view}: beta")));
f.host
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Button { id: "refresh".into() }, f.inv())
.unwrap();
let refreshed = f.host.service_detail("test.detail-views", view, "beta", f.inv()).unwrap();
assert_ne!(tabs, refreshed);
}
let selections = [("tree", "alpha"), ("list", "beta"), ("services", "alpha")];
for (view, key) in selections {
f.host
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Select { key: key.into() }, f.inv())
.unwrap();
}
for (view, key) in selections {
let tabs = f.host.service_detail("test.detail-views", view, key, f.inv()).unwrap();
let ide_plugin::ServicePane::Text(text) = &tabs[0].body else { panic!("text detail") };
assert!(text.starts_with(&format!("{view}: {key}")));
}
}
/// `hello` as a services view that may run `sh`, granted or not.
@@ -3308,6 +3840,30 @@ fn service_logs_need_the_grant_and_a_log_tab() {
assert!(!marker.exists(), "a cancelled request starts nothing");
}
#[cfg(unix)]
#[test]
fn ordinary_detail_views_reuse_log_grants_and_streaming() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |t| {
services_variant(t).replace("location = \"services\"", "location = \"right\"\ndetail = true").replace(
"required = [\"editor.read\", \"editor.write\"]",
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]",
)
});
f.install(&pkg);
f.host.set_capability("hello", "process:sh", false).unwrap();
assert_eq!(
stream(&f, "echo allowed", &[], &Default::default(), |_| {}).unwrap_err().kind,
ErrorKind::PermissionDenied
);
f.host.set_capability("hello", "process:sh", true).unwrap();
f.host.set_program_path(Arc::new(|| Some(PathBuf::from("/bin").into_os_string())));
let lines = Mutex::new(Vec::new());
assert_eq!(stream(&f, "echo allowed", &[], &Default::default(), |l| lines.lock().push(l.text)).unwrap(), Some(0));
assert_eq!(lines.into_inner(), ["allowed"]);
}
#[cfg(unix)]
#[test]
fn service_log_cancel_kills_the_child() {
@@ -3863,3 +4419,342 @@ fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_pro
f.host.set_capability("test.path-events", "events", false).unwrap();
assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events"));
}
#[test]
fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.take_events();
let two_mib = "x".repeat(2 << 20);
f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Message { text: two_mib.clone() }, f.inv()).unwrap();
let echoed = f
.take_events()
.into_iter()
.find_map(|(_, e)| match e {
PluginEvent::ViewMessage { view, message, .. } if view == "jobs" => Some(message),
_ => None,
})
.expect("2 MiB view message round-trips");
assert_eq!(echoed.len(), 2 << 20);
let too_large = "x".repeat((8 << 20) + 1);
let err =
f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Message { text: too_large }, f.inv()).unwrap_err();
assert!(err.message.contains("limited to"), "{}", err.message);
}
#[test]
fn web_log_tabs_use_the_web_pty_permission_boundary() {
use ide_host::plugins::web::WebPtyCommand;
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&sample("web-log"), &Consent::default()).unwrap();
assert!(info.capabilities.iter().any(|c| c.id == "process:bash" && !c.granted));
assert_eq!(f.run("web-log", "ping", None).unwrap().as_deref(), Some("Web log ready"));
for command in ["open", "denied"] {
f.take_events();
assert_eq!(f.run("web-log", command, None).unwrap(), None);
assert!(
f.take_events()
.iter()
.any(|(_, event)| matches!(event, PluginEvent::ViewMessage { plugin_id, view, message }
if plugin_id == "web-log" && view == "logs" && message == command))
);
}
let command = |program: &str| WebPtyCommand {
program: program.into(),
args: vec!["--noprofile".into(), "--norc".into()],
env: Default::default(),
cols: 240,
rows: 40,
};
for program in ["bash", "sh", "/bin/bash"] {
assert_eq!(
f.host.web_pty_options("web-log", "logs", "main", command(program)).unwrap_err().kind,
ErrorKind::PermissionDenied
);
}
#[cfg(unix)]
{
f.host.set_capability("web-log", "process:bash", true).unwrap();
let options = f.host.web_pty_options("web-log", "logs", "main", command("bash")).unwrap();
assert_eq!(options.shell.as_deref(), f.host.program_for("web-log", "bash").unwrap().to_str());
assert_eq!(options.cwd.0, f.project);
assert!(options.env.is_empty());
assert!(!options.shell_integration);
assert!(f.host.web_pty_options("web-log", "logs", "main", command("sh")).is_err());
f.host.set_capability("web-log", "process:bash", false).unwrap();
assert!(f.host.web_pty_options("web-log", "logs", "main", command("bash")).is_err());
}
}
#[test]
fn web_ptys_require_a_current_exact_program_grant_and_project() {
use ide_host::plugins::web::WebPtyCommand;
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&sample("web-pty"), &Consent::default()).unwrap();
assert!(info.capabilities.iter().any(|c| c.id == "process:git" && !c.granted));
let command = || WebPtyCommand {
program: "git".into(),
args: vec!["--version".into()],
env: Default::default(),
cols: 80,
rows: 24,
};
let options = |cmd| f.host.web_pty_options("web-pty", "terminal", "main", cmd);
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
f.host.set_capability("web-pty", "process:git", true).unwrap();
let approved = f.host.program_for("web-pty", "git").unwrap();
let got = options(command()).unwrap();
assert_eq!(got.shell.as_deref(), approved.to_str());
assert!(approved.is_absolute());
assert_eq!(got.cwd.0, f.project);
assert!(!got.shell_integration);
assert_eq!(f.run("web-pty", "ping", None).unwrap().as_deref(), Some("Web PTY ready"));
for program in ["sh", "bash", "git-other", "./git", "/tmp/git"] {
let cmd = WebPtyCommand { program: program.into(), ..command() };
assert!(options(cmd).is_err(), "{program}");
}
for cmd in [
WebPtyCommand { cols: 0, ..command() },
WebPtyCommand { rows: 501, ..command() },
WebPtyCommand { args: vec!["x".repeat(4097)], ..command() },
WebPtyCommand { args: vec!["x".into(); 257], ..command() },
WebPtyCommand { args: vec!["\0".into()], ..command() },
WebPtyCommand { env: [("PATH".into(), "/tmp".into())].into(), ..command() },
WebPtyCommand { env: [("a=b".into(), "x".into())].into(), ..command() },
WebPtyCommand { env: [("a".into(), "x".repeat(4097))].into(), ..command() },
] {
assert!(options(cmd).is_err());
}
let got = options(WebPtyCommand { env: [("OPENIDEA_WEB_PTY_TEST_NEW".into(), "yes".into())].into(), ..command() })
.unwrap();
assert_eq!(got.env["OPENIDEA_WEB_PTY_TEST_NEW"], "yes");
assert!(f.host.web_pty_options("web-pty", "missing", "main", command()).is_err());
f.host.set_workspace(None);
assert!(options(command()).is_err());
let mut untrusted = TestWorkspace::new(&f.project);
Arc::get_mut(&mut untrusted).unwrap().trusted = false;
f.host.set_workspace(Some(untrusted));
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
f.host.set_workspace(Some(f.workspace.clone()));
std::fs::rename(&f.project, f.project.with_extension("moved")).unwrap();
assert!(options(command()).unwrap_err().message.contains("no longer exists"));
std::fs::rename(f.project.with_extension("moved"), &f.project).unwrap();
f.host.set_capability("web-pty", "process:git", false).unwrap();
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
f.host.set_enabled("web-pty", false).unwrap();
assert!(options(command()).is_err());
f.host.uninstall("web-pty").unwrap();
assert!(options(command()).is_err());
}
#[cfg(unix)]
#[test]
fn web_ptys_keep_the_approved_file_and_require_api_1_3() {
use ide_host::plugins::web::WebPtyCommand;
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits::default());
let bin = f.plugins_dir.parent().unwrap().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let executable = bin.join("git");
std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = bin.clone();
f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string())));
f.install(&sample("web-pty"));
let command = || WebPtyCommand { program: "git".into(), args: vec![], env: Default::default(), cols: 80, rows: 24 };
f.host.set_program_path(Arc::new(|| Some("/unapproved-path".into())));
assert_eq!(
f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap().shell.unwrap(),
executable.canonicalize().unwrap().to_string_lossy()
);
std::fs::write(&executable, "#!/bin/sh\nexit 42\n").unwrap();
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap_err().message.contains("changed"));
std::fs::remove_file(&executable).unwrap();
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).is_err());
let old = tempfile::tempdir().unwrap();
let pkg = old.path().join("old");
copy_dir(&sample("web-pty"), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap().replace("^1.3", "^1.2");
std::fs::write(manifest, text).unwrap();
let f = fixture(Limits::default());
f.install(&pkg);
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap_err().message.contains("1.3"));
}
#[tokio::test]
async fn web_ptys_are_capped_across_windows_and_end_with_the_plugin() {
use ide_host::{Host, HostConfig, plugins::web::WebPtyCommand};
let f = fixture(Limits::default());
f.install(&sample("web-pty"));
let root = tempfile::tempdir().unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: root.path().join("data"),
local_data_dir: root.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let spawn = |window: &str| {
f.host.spawn_web_pty(
&host,
"web-pty",
"terminal",
window,
WebPtyCommand {
program: "git".into(),
args: vec!["hash-object".into(), "--stdin".into()],
env: Default::default(),
cols: 80,
rows: 24,
},
Arc::new(|_| {}),
)
};
*f.workspace.project_queries_left.lock() = Some(1);
assert!(spawn("main").is_err(), "project closed while spawning");
assert!(host.terminals.list().is_empty());
*f.workspace.project_queries_left.lock() = None;
let mut ids = Vec::new();
for i in 0..8 {
ids.push(spawn(&format!("window-{i}")).unwrap().id);
}
assert!(spawn("ninth-window").unwrap_err().message.contains("8 PTYs"));
host.terminals.kill(ids[0]).unwrap();
let replacement = spawn("another-window").unwrap();
f.host.reload("web-pty").unwrap();
assert!(!host.terminals.contains(replacement.id));
assert!(ids.into_iter().all(|id| !host.terminals.contains(id)));
let id = spawn("main").unwrap().id;
f.host.set_capability("web-pty", "process:git", false).unwrap();
assert!(!host.terminals.contains(id));
assert!(spawn("main").is_err());
f.host.set_capability("web-pty", "process:git", true).unwrap();
let id = spawn("main").unwrap().id;
f.host.set_enabled("web-pty", false).unwrap();
assert!(!host.terminals.contains(id));
f.host.set_enabled("web-pty", true).unwrap();
let id = spawn("main").unwrap().id;
f.host.forget_client("main");
assert!(!host.terminals.contains(id));
assert!(spawn("main").is_err());
f.host.reopen_client("main");
let id = spawn("main").unwrap().id;
f.host.uninstall("web-pty").unwrap();
assert!(!host.terminals.contains(id));
}
#[test]
fn marker_icons_ship_sanitized_light_and_dark_assets() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("plugin");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
let manifest = |engine: &str, icon: &str, dark: &str| {
format!(
r#"
id = "marker-icons"
name = "Marker Icons"
version = "1.0.0"
engines.openidea = "{engine}"
[[contributes.markerIcons]]
id = "resource"
icon = "{icon}"
iconDark = "{dark}"
"#
)
};
let clean = r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#;
std::fs::write(pkg.join("icons/resource.svg"), clean).unwrap();
std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap();
for (engine, icon, dark) in [
("^1.0", "icons/resource.svg", "icons/resource_dark.svg"),
("^1.3", "../outside.svg", "icons/resource_dark.svg"),
("^1.3", "icons/missing.svg", "icons/resource_dark.svg"),
("^1.3", "icons/resource.svg", "icons/missing.svg"),
] {
std::fs::write(pkg.join("plugin.toml"), manifest(engine, icon, dark)).unwrap();
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err());
}
std::fs::write(pkg.join("plugin.toml"), manifest("^1.3", "icons/resource.svg", "icons/resource_dark.svg")).unwrap();
#[cfg(unix)]
{
let outside = tmp.path().join("outside.svg");
std::fs::write(&outside, clean).unwrap();
std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap();
std::os::unix::fs::symlink(outside, pkg.join("icons/resource_dark.svg")).unwrap();
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err());
std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap();
}
let mut unsafe_svgs = vec![
"<svg><script>alert(1)</script></svg>".to_owned(),
"<svg onload=\"alert(1)\"/>".to_owned(),
"<svg><foreignObject/></svg>".to_owned(),
"<svg><rect style=\"fill:url(https://evil/x)\"/></svg>".to_owned(),
"<svg><rect style=\"background-image:image-set('https://evil/x' 1x)\"/></svg>".to_owned(),
format!("<svg>{}</svg>", " ".repeat(64 << 10)),
];
for attr in ["href", "xlink:href"] {
for scheme in ["http://evil/x", "data:image/svg+xml,x", "javascript:alert(1)"] {
unsafe_svgs.push(format!("<svg><use {attr}=\"{scheme}\"/></svg>"));
}
}
for path in ["icons/resource.svg", "icons/resource_dark.svg"] {
for svg in &unsafe_svgs {
std::fs::write(pkg.join(path), svg).unwrap();
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "accepted {path}: {svg}");
}
std::fs::write(pkg.join(path), clean).unwrap();
}
std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap();
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let assets = f.host.assets("marker-icons", "markerIcon/resource/").unwrap();
assert_eq!(assets.len(), 2);
assert!(assets.iter().any(|(id, svg)| id.ends_with("/light") && svg.contains("<rect")));
assert!(assets.iter().any(|(id, svg)| id.ends_with("/dark") && svg.contains("<circle")));
let revision = f.host.ui_items("markerIcons")[0].decl["revision"].clone();
std::fs::write(pkg.join("icons/resource.svg"), clean.replace("width=\"16\"", "width=\"14\"")).unwrap();
let updated = f.install(&pkg);
assert!(updated.error.is_none(), "{:?}", updated.error);
assert_ne!(f.host.ui_items("markerIcons")[0].decl["revision"], revision);
let assets = f.host.assets("marker-icons", "markerIcon/resource/").unwrap();
assert!(assets.iter().any(|(id, svg)| id.ends_with("/light") && svg.contains("width=\"14\"")));
std::fs::remove_dir_all(pkg).unwrap();
assert_eq!(f.host.assets("marker-icons", "markerIcon/resource/").unwrap(), assets);
f.host.set_enabled("marker-icons", false).unwrap();
assert!(f.host.assets("marker-icons", "markerIcon/resource/").is_err());
}
#[test]
fn marker_providers_preserve_names_and_package_paths() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("plugin");
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 = [\"line-markers\"]\nlanguages = [\"text\"]\n"),
)
.unwrap();
f.install(&pkg);
let doc = ide_plugin::ProviderDocument {
path: f.project.join("markers.txt"),
language: "text".into(),
version: 1,
text: "marker-icons\nordinary\ncustom\nlegacy".into(),
};
assert!(eventually(|| f.host.provide_line_markers(&doc).len() == 4));
let markers = f.host.provide_line_markers(&doc);
assert_eq!(
markers.iter().map(|(_, m)| m.icon.as_deref().unwrap()).collect::<Vec<_>>(),
["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"]
);
}
+18
View File
@@ -211,6 +211,24 @@ fn persists_user_decisions_locations_revocation_and_one_time_migration() {
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
}
#[test]
fn an_inherited_folder_is_trusted_for_this_process_only() {
let tmp = tempfile::tempdir().unwrap();
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
let root = tmp.path().join("workspaces/app");
std::fs::create_dir_all(&root).unwrap();
let trust = TrustStore::load(&config, &data);
assert!(!trust.trusted(&root).unwrap());
trust.inherit(&root).unwrap();
assert!(trust.trusted(&root).unwrap());
assert!(!trust.trusted(root.parent().unwrap()).unwrap(), "only that folder");
assert!(trust.status(&root).unwrap().decided, "the trust prompt doesn't ask again");
assert!(trust.decisions().unwrap().projects.is_empty(), "no decision is recorded");
assert!(!TrustStore::load(&config, &data).trusted(&root).unwrap(), "not persisted");
trust.decide(&root, false, false).unwrap();
assert!(!trust.trusted(&root).unwrap(), "a decision to restrict it still wins");
}
#[cfg(unix)]
fn script(root: &Path, name: &str, marker: &str) {
use std::os::unix::fs::PermissionsExt;
+37
View File
@@ -268,6 +268,43 @@ impl ProjectIndex {
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
}
/// Scoped symbol search with cooperative interruption, including lock waits.
/// `None` means interrupted; no partial results are returned.
pub fn find_symbols_until(
&self,
query: &str,
filter: SymbolFilter,
limit: usize,
include: &(impl Fn(&Path) -> bool + Sync),
stopped: &(impl Fn() -> bool + Sync),
) -> Option<Vec<SymbolMatch>> {
let recent = loop {
if stopped() {
return None;
}
if let Some(recent) = self.shared.recent.try_lock_for(Duration::from_millis(1)) {
break recent.clone();
}
};
let data = loop {
if stopped() {
return None;
}
if let Some(data) = self.shared.data.try_read_for(Duration::from_millis(1)) {
break data;
}
};
matching::find_symbols_until(
&data,
&matching::Recents { paths: &recent },
query,
filter,
limit,
include,
stopped,
)
}
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
pub fn symbols_named(&self, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
+44 -10
View File
@@ -121,22 +121,35 @@ fn scan(
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Vec<Cand> {
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
}
fn scan_until(
data: &IndexData,
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
stopped: &(impl Fn() -> bool + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Option<Vec<Cand>> {
data.files
.par_iter()
.enumerate()
.with_min_len(256)
.fold(Vec::new, |mut acc, (slot, e)| {
.try_fold(Vec::new, |mut acc, (slot, e)| {
if stopped() {
return None;
}
if let Some(e) = e {
per_file(slot as u32, e, &mut acc);
if acc.len() >= pool * 2 {
acc = keep_best(acc, pool, tiebreak);
}
}
acc
Some(acc)
})
.reduce(Vec::new, |mut a, b| {
.try_reduce(Vec::new, |mut a, b| {
a.extend(b);
if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a }
Some(if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a })
})
}
@@ -423,9 +436,21 @@ pub(crate) fn find_symbols(
filter: SymbolFilter,
limit: usize,
) -> Vec<SymbolMatch> {
find_symbols_until(data, recents, query, filter, limit, &|_| true, &|| false).unwrap_or_default()
}
pub(crate) fn find_symbols_until(
data: &IndexData,
recents: &Recents,
query: &str,
filter: SymbolFilter,
limit: usize,
include: &(impl Fn(&Path) -> bool + Sync),
stopped: &(impl Fn() -> bool + Sync),
) -> Option<Vec<SymbolMatch>> {
let q: String = query.trim().chars().filter(|c| !c.is_whitespace()).collect();
if q.is_empty() || limit == 0 {
return Vec::new();
return Some(Vec::new());
}
let (container_q, name_q) = split_symbol_query(&q);
let name = Needle::new(name_q);
@@ -436,13 +461,16 @@ pub(crate) fn find_symbols(
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
};
let pool = limit * 4 + 64;
let cands = scan(data, pool, &tiebreak, |slot, e, acc| {
if e.symbols.is_empty() {
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
if e.symbols.is_empty() || !include(&e.path) {
return;
}
with_matcher(|m| {
let mut buf = Vec::new();
for (i, s) in e.symbols.iter().enumerate() {
if stopped() {
break;
}
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
continue;
}
@@ -451,11 +479,15 @@ pub(crate) fn find_symbols(
}
}
});
});
})?;
if stopped() {
return None;
}
let mut out: Vec<(u32, Cand, Vec<u32>)> = with_matcher(|m| {
let mut buf = Vec::new();
cands
.into_iter()
.take_while(|_| !stopped())
.filter_map(|c| {
let s = sym(&c);
let e = file(data, c.slot);
@@ -472,7 +504,8 @@ pub(crate) fn find_symbols(
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
});
out.truncate(limit);
out.into_iter()
let results = out
.into_iter()
.map(|(score, c, indices)| {
let e = file(data, c.slot);
let s = &e.symbols[c.sym as usize];
@@ -487,7 +520,8 @@ pub(crate) fn find_symbols(
indices,
}
})
.collect()
.collect();
(!stopped()).then_some(results)
}
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
+77
View File
@@ -652,3 +652,80 @@ fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
assert!(symbols.iter().any(|s| s.name == "find"));
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
}
#[test]
fn symbol_search_can_stop_during_scanning_and_lock_waits() {
use std::sync::atomic::AtomicUsize;
let fx = sample();
let idx = fx.open();
let calls = AtomicUsize::new(0);
let stopped = || calls.fetch_add(1, Ordering::Relaxed) >= 5;
assert!(idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &stopped).is_none());
assert!(calls.load(Ordering::Relaxed) > 5);
for recent in [false, true] {
let _data = (!recent).then(|| idx.shared.data.write());
let _recent = recent.then(|| idx.shared.recent.lock());
let deadline = std::time::Instant::now() + Duration::from_millis(10);
assert!(
idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &|| std::time::Instant::now() >= deadline)
.is_none()
);
}
}
#[test]
fn scoped_symbol_search_preserves_fuzzy_ranking_within_allowed_paths() {
let fx = sample();
let idx = fx.open();
let allowed = fx.root.join("src/order");
let symbols =
idx.find_symbols_until("OCo", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false).unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "OrderController");
assert!(
idx.find_symbols_until("OtherCollection", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false)
.unwrap()
.is_empty()
);
}
#[cfg(unix)]
#[test]
fn incremental_symbols_resolve_symlink_ancestors_before_scope_checks() {
let fx = Fixture::new(&[("src/inside.rs", "fn inside_symbol() {}\n")]);
let outside = fx._dir.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("secret.rs"), "fn secret_symbol() {}\n").unwrap();
std::os::unix::fs::symlink(&outside, fx.root.join("escape")).unwrap();
std::os::unix::fs::symlink(fx.root.join("src"), fx.root.join("alias")).unwrap();
let idx = fx.open();
idx.update(&[fx.root.join("escape/secret.rs"), fx.root.join("alias/inside.rs")], &[]);
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "secret_symbol", SymbolFilter::All).is_empty());
let inside = idx.find_symbols("inside_symbol", SymbolFilter::All, 10);
assert_eq!(inside.len(), 1);
assert_eq!(inside[0].path.0, fx.root.join("src/inside.rs"));
}
#[cfg(unix)]
#[test]
fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
let fx = Fixture::new(&[("src/old.rs", "fn old_symbol() {}\n")]);
drop(fx.open());
let outside = fx._dir.path().join("outside");
std::fs::rename(fx.root.join("src"), &outside).unwrap();
std::os::unix::fs::symlink(outside, fx.root.join("src")).unwrap();
let initial = Arc::new(Mutex::new(Vec::new()));
let events = initial.clone();
let sink: SharedSink = Arc::new(move |event: IdeEvent| {
if let IdeEvent::IndexState { symbols, .. } = event {
events.lock().push(symbols);
}
});
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
let idx = ProjectIndex::open(&fx.root, &fx.cache, Vec::new(), Vec::new(), languages, Arc::new(Syntax::new()), sink)
.unwrap();
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "old_symbol", SymbolFilter::All).is_empty());
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
+8 -2
View File
@@ -174,7 +174,10 @@ impl Shared {
Ok(records) => {
let entries: Vec<FileEntry> = records
.into_par_iter()
.filter(|(rel, _)| !filter.is_excluded(&root.join(rel), false))
.filter(|(rel, _)| {
let path = root.join(rel);
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
@@ -420,7 +423,10 @@ impl Shared {
let mut seen = HashSet::new();
let mut by_dir: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
for path in changed {
if !seen.insert(path.clone()) {
// A watcher path may have a symlink ancestor even when its spelling
// is inside the root. Resolve it on the worker before reading it.
let Ok(path) = dunce::canonicalize(&path) else { continue };
if !filter.contains(&path) || !seen.insert(path.clone()) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
@@ -22,8 +22,49 @@ impl Syntax {
if language.as_str() == "properties" {
return Ok(properties(text));
}
if language.as_str() == "xml" {
// Only Spring wiring that mentions property placeholders can declare
// a configuration key; skip parsing unrelated XML (POMs, mappers,
// manifests) during project scans.
if !text.contains("<prop") && !text.contains("placeholder") && !text.contains("Placeholder") {
return Ok(Vec::new());
}
return self.with_parsed(language, text, cancelled, |tree| tree.spring_xml_keys(cancelled));
}
self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled))
}
/// Name of the Spring `${name[:default]}` placeholder covering `pos` in
/// `text`, if the position lies anywhere inside its braces. The name stops
/// at the first `:` or `}`; inside a nested default (`${a:${b}}`) the inner
/// braces name `b`. Empty names (`${}`) name nothing.
pub fn configuration_placeholder(&self, text: &str, pos: Pos) -> Option<String> {
let at = byte_at(text, pos)?;
let mut braces = Vec::new();
let mut best: Option<(usize, usize)> = None;
for (i, byte) in text.bytes().enumerate() {
match byte {
b'{' => braces.push((i, None, i > 0 && text.as_bytes()[i - 1] == b'$')),
b':' => {
if let Some((_, end, true)) = braces.last_mut() {
end.get_or_insert(i);
}
}
b'}' => {
if let Some((open, end, true)) = braces.pop() {
let open = open - 1;
if open <= at && at <= i && best.is_none_or(|(previous, _)| open > previous) {
best = Some((open, end.unwrap_or(i)));
}
}
}
_ => {}
}
}
let (open, end) = best?;
let name = text[open + 2..end].trim();
(!name.is_empty()).then(|| name.to_owned())
}
}
/// Spring's property lookups whose first argument names a configuration key.
@@ -68,6 +109,134 @@ impl ParsedTree<'_> {
}
out
}
/// Spring property declarations in XML wiring: `<prop key="name">` values
/// (util:properties, configurer `<props>`) and `<property name="name"
/// value="...">` entries under a placeholder configurer bean. Elements that
/// only reference a property file (`<context:property-placeholder>`,
/// `<util:properties location="...">`) declare no key of their own; the
/// configuration scan finds the keys inside the referenced files.
pub fn spring_xml_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
let mut out = Vec::new();
spring_xml(self.tree.root_node(), self.text, &LineIndex::new(self.text), &mut out, cancelled, 0, false);
out
}
}
/// Bean classes whose `<property name="key" value="...">` entries feed
/// placeholder resolution (the placeholder configurers).
fn placeholder_bean(class: &str) -> bool {
class.to_ascii_lowercase().contains("placeholder")
}
fn local_name(tag: &str) -> &str {
tag.rsplit(':').next().unwrap_or(tag)
}
fn spring_xml(
node: Node<'_>,
text: &str,
lines: &LineIndex<'_>,
out: &mut Vec<ConfigurationKey>,
cancelled: &dyn Fn() -> bool,
depth: usize,
configurer: bool,
) {
if depth > 64 || cancelled() || out.len() >= 20_000 {
return;
}
if node.kind() != "element" {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
spring_xml(child, text, lines, out, cancelled, depth + 1, configurer);
}
return;
}
let mut cursor = node.walk();
let children: Vec<_> = node.named_children(&mut cursor).collect();
let mut tag = "";
let mut inside = configurer;
let mut content: Vec<Node<'_>> = Vec::new();
for child in children {
match child.kind() {
"STag" | "EmptyElemTag" => {
let mut attrs_cursor = child.walk();
let attrs: Vec<_> = child.named_children(&mut attrs_cursor).collect();
let mut attributes: Vec<(&str, String, Range)> = Vec::new();
for (i, attr) in attrs.iter().enumerate() {
if i == 0 && attr.kind() == "Name" {
tag = local_name(&text[attr.byte_range()]);
continue;
}
if attr.kind() != "Attribute" {
continue;
}
let mut parts_cursor = attr.walk();
let mut name = "";
let mut value = None;
for part in attr.named_children(&mut parts_cursor) {
match part.kind() {
"Name" => name = &text[part.byte_range()],
"AttValue" => value = Some(quoted_value(part, text, lines)),
_ => {}
}
}
if let Some((value, range)) = value {
attributes.push((name, value, range));
}
}
let attribute = |wanted: &str| attributes.iter().find(|(name, _, _)| *name == wanted);
if tag == "prop"
&& let Some((_, key, range)) = attribute("key")
&& !key.is_empty()
&& key.len() <= 1024
{
out.push(ConfigurationKey { name: key.clone(), range: *range });
}
if tag == "property"
&& configurer
&& let (Some((_, name, range)), Some(_)) = (attribute("name"), attribute("value"))
&& !name.is_empty()
&& name.len() <= 1024
{
out.push(ConfigurationKey { name: name.clone(), range: *range });
}
if tag == "bean"
&& let Some((_, class, _)) = attribute("class")
{
inside = placeholder_bean(class);
}
}
"content" => content.extend(child.named_children(&mut cursor).filter(|n| n.kind() == "element")),
// Error recovery can nest elements without a `content` node.
"element" => content.push(child),
_ => {}
}
}
for element in content {
spring_xml(element, text, lines, out, cancelled, depth + 1, inside);
}
}
/// The decoded text of an `AttValue` and the range of its quoted contents.
fn quoted_value(node: Node<'_>, text: &str, lines: &LineIndex<'_>) -> (String, Range) {
let raw = &text[node.byte_range()];
let quoted = raw.len() >= 2
&& ((raw.starts_with('"') && raw.ends_with('"')) || (raw.starts_with('\'') && raw.ends_with('\'')));
let (value, range) = if quoted {
(&raw[1..raw.len() - 1], lines.range(node.start_byte() + 1, node.end_byte() - 1))
} else {
(raw, lines.range(node.start_byte(), node.end_byte()))
};
(unescape_xml(value), range)
}
/// XML's five predefined entities; unknown references keep their text.
fn unescape_xml(value: &str) -> String {
if !value.contains('&') {
return value.to_owned();
}
value.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
fn yaml(
@@ -317,4 +486,107 @@ class A {
);
assert_eq!(keys[0].range.end.line, 2);
}
#[test]
fn placeholder_names_cover_the_whole_reference() {
let text = "name: ${db.url:8080}\nplain: value\nempty: ${}\nnested: ${a:${b.c}}\nspaced: ${ db.h }\n";
let syntax = Syntax::new();
let at = |needle: &str, offset: u32| {
let line = text.lines().position(|l| l.contains(needle)).unwrap();
let line_text = text.lines().nth(line).unwrap();
let byte = line_text.find(needle).unwrap();
let col = line_text[..byte].encode_utf16().count() as u32 + offset;
syntax.configuration_placeholder(text, Pos::new(line as u32, col))
};
// On the `$`, inside the name and on the default all name `db.url`.
assert_eq!(at("${db.url", 0), Some("db.url".into()));
assert_eq!(at("db.url", 1), Some("db.url".into()));
assert_eq!(at("8080", 1), Some("db.url".into()));
assert_eq!(at("plain", 1), None);
assert_eq!(at("empty", 3), None);
// A nested default names its own property; the outer braces name `a`.
assert_eq!(at("${b.c", 2), Some("b.c".into()));
assert_eq!(at("${a:", 2), Some("a".into()));
assert_eq!(at("db.h", 1), Some("db.h".into()));
assert_eq!(syntax.configuration_placeholder("no placeholder", Pos::new(0, 3)), None);
assert_eq!(syntax.configuration_placeholder("trailing ${open", Pos::new(0, 12)), None);
}
#[test]
fn nested_placeholder_defaults_are_brace_balanced() {
let syntax = Syntax::new();
let text = "${outer:${inner}suffix}";
for col in 0..text.len() {
let expected = if (8..=15).contains(&col) { "inner" } else { "outer" };
assert_eq!(
syntax.configuration_placeholder(text, Pos::new(0, col as u32)).as_deref(),
Some(expected),
"column {col}"
);
}
let text = "${outer:${middle:${inner}}suffix}";
for (needle, expected) in [("outer", "outer"), ("middle", "middle"), ("inner", "inner"), ("suffix", "outer")] {
assert_eq!(
syntax.configuration_placeholder(text, Pos::new(0, text.find(needle).unwrap() as u32)).as_deref(),
Some(expected)
);
}
assert_eq!(
syntax.configuration_placeholder("${outer:{default}suffix}", Pos::new(0, 19)).as_deref(),
Some("outer")
);
assert_eq!(syntax.configuration_placeholder("${outer:${inner}suffix", Pos::new(0, 18)), None);
}
#[test]
fn configuration_placeholders_use_utf16_columns() {
let syntax = Syntax::new();
for text in ["message=你好😀 ${db.user}", "message: 你好😀 ${db.user}", "<bean value=\"你好😀 ${db.user}\"/>"]
{
let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32;
assert_eq!(syntax.configuration_placeholder(text, Pos::new(0, col + 2)).as_deref(), Some("db.user"));
}
}
#[test]
fn spring_xml_declares_prop_and_configurer_property_keys() {
let text = r#"<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans">
<context:property-placeholder location="classpath:db.properties"/>
<util:properties id="db">
<prop key="db.url">jdbc:h2:mem:test</prop>
</util:properties>
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="properties">
<props>
<prop key="db.user">sa</prop>
</props>
</property>
</bean>
<bean class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="url" value="jdbc:h2:mem"/>
</bean>
<bean class="org.springframework.beans.factory.config.PropertySourcesPlaceholderConfigurer">
<property name="system.mode" value="test"/>
</bean>
</beans>
"#;
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
// The configurer's own `properties` injection names no property, and a
// plain bean's `<property name="url">` is not an environment property.
assert_eq!(names, ["db.url", "db.user", "system.mode"]);
// The range covers the quoted key attribute's value.
let line = text.lines().position(|l| l.contains("db.url")).unwrap() as u32;
let start = keys[0].range.start;
assert_eq!((start.line, keys[0].range.end.line), (line, line));
assert_eq!(
&text.lines().nth(line as usize).unwrap()[start.col as usize..keys[0].range.end.col as usize],
"db.url"
);
}
#[test]
fn xml_without_property_markers_is_not_parsed() {
let text = "<mappers><mapper resource='a.xml'/><select id='x'>select 1</select></mappers>";
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
assert!(keys.is_empty());
}
}
+130 -31
View File
@@ -20,6 +20,16 @@ const MAX_CACHED_KEYS: usize = 100_000;
#[derive(Default)]
pub(crate) struct ConfigurationCache {
files: HashMap<PathBuf, CachedFile>,
roots: HashMap<PathBuf, CachedPaths>,
}
struct CachedPaths {
paths: Vec<PathBuf>,
incomplete: Option<ReferencesIncomplete>,
}
impl ConfigurationCache {
pub(crate) fn invalidate_paths(&mut self) {
self.roots.clear();
}
}
struct CachedFile {
stamp: (SystemTime, u64),
@@ -49,6 +59,9 @@ fn language(path: &Path) -> Option<LanguageId> {
match path.extension()?.to_str()? {
"yaml" | "yml" => Some(LanguageId::new("yaml")),
"properties" => Some(LanguageId::new("properties")),
// Spring XML wiring declares properties in `<prop key="...">` values
// and placeholder-configurer `<property name="...">` entries.
"xml" => Some(LanguageId::new("xml")),
_ => None,
}
}
@@ -158,6 +171,16 @@ impl LspManager {
} else {
None
};
// A click inside a Spring `${name}` placeholder sits on a value, not a
// key: the name names the property whose declaration lives in
// configuration files or Spring XML, with Java binding members still
// answering through the Java index.
let placeholder = if config && key.is_none() && !usages && snap.text.as_str().len() as u64 <= MAX_BYTES {
syntax().configuration_placeholder(snap.text.as_str(), pos)
} else {
None
};
let key = placeholder.clone().or(key);
if config && key.is_none() {
return Ok(None);
}
@@ -178,7 +201,8 @@ impl LspManager {
return Ok(None);
}
}
let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await;
let result =
self.configuration_query(snap, pos, key, placeholder.is_some(), usages, include_declaration, cancel).await;
match result {
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()),
Err(e) => {
@@ -189,22 +213,63 @@ impl LspManager {
}
}
#[allow(clippy::too_many_arguments)]
async fn configuration_query(
&self,
snap: &DocSnap,
pos: Pos,
key: Option<String>,
placeholder: bool,
usages: bool,
include_declaration: bool,
cancel: &CancellationToken,
) -> IdeResult<Option<References>> {
let (server, run) = self.jdtls(&snap.path, cancel).await?;
// A `${name}` reference resolves from configuration files and Spring
// XML without Java; those declarations are collected first so
// navigation survives an unavailable or outdated Java extension.
let local = if placeholder {
let name = key.clone().unwrap_or_default();
let path = snap.path.clone();
let project_root = self.project_root.clone();
let root = tokio::task::spawn_blocking(move || {
path.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|p| p.starts_with(&project_root))
.find(|p| {
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
.iter()
.any(|name| p.join(name).is_file())
})
.map(Path::to_path_buf)
.unwrap_or(project_root)
})
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
Some(
self.configuration_declarations(
vec![Pattern { name, relaxed: true }],
std::slice::from_ref(&root),
cancel,
)
.await?,
)
} else {
None
};
let answered = local.as_ref().is_some_and(|local| !local.locations.is_empty());
let (server, run) = match self.jdtls(&snap.path, cancel).await {
Ok(found) => found,
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()),
Err(_) if answered => return Ok(local),
Err(e) => return Err(e),
};
if !run.caps.read().commands().iter().any(|command| command == COMMAND) {
return Ok(None);
return Ok(if placeholder { local } else { None });
}
let target = Target { server: server.clone(), run };
let position = snap.text.ide_to_lsp(Encoding::Utf16, pos);
let response: Response = self
let response: Response = match self
.call(
&target,
"workspace/executeCommand",
@@ -214,11 +279,17 @@ impl LspManager {
Some(cancel),
crate::LONG_TIMEOUT,
)
.await?;
.await
{
Ok(response) => response,
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()),
Err(_) if answered => return Ok(local),
Err(e) => return Err(e),
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if response.keys.is_empty() {
if response.keys.is_empty() && !placeholder {
return Ok(response
.incomplete
.then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() }));
@@ -229,22 +300,18 @@ impl LspManager {
..Default::default()
};
self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref()));
if key.is_none() || usages {
if let Some(local) = local {
result.locations.extend(local.locations);
result.incomplete = result.incomplete.or(local.incomplete);
}
if key.is_none() || usages || placeholder {
let roots: Vec<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
let overlays: HashMap<PathBuf, String> = self
.docs
.lock()
.values()
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
.collect();
let cache = self.configuration_cache.clone();
let cancelled = cancel.clone();
let keys = response.keys;
let files =
tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled))
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
let patterns = if placeholder {
vec![Pattern { name: key.clone().unwrap_or_default(), relaxed: true }]
} else {
response.keys
};
let files = self.configuration_declarations(patterns, &roots, cancel).await?;
result.locations.extend(files.locations);
result.incomplete = result.incomplete.or(files.incomplete);
}
@@ -266,6 +333,29 @@ impl LspManager {
crate::convert::dedup_locations(&mut result.locations);
Ok(Some(result))
}
/// Configuration-file and Spring-XML declarations matching `patterns` under
/// `roots`, with open editor contents taking precedence over disk.
async fn configuration_declarations(
&self,
patterns: Vec<Pattern>,
roots: &[PathBuf],
cancel: &CancellationToken,
) -> IdeResult<References> {
let overlays: HashMap<PathBuf, String> = self
.docs
.lock()
.values()
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
.collect();
let cache = self.configuration_cache.clone();
let cancelled = cancel.clone();
let roots = roots.to_vec();
tokio::task::spawn_blocking(move || configuration_files(&roots, &patterns, &overlays, &cache, &cancelled))
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))
}
}
fn in_module(root: &Path, path: &Path) -> bool {
@@ -296,11 +386,15 @@ fn configuration_files(
) -> References {
let mut result = References::default();
let mut paths = std::collections::BTreeSet::new();
// Do not scan a source root twice when its enclosing module is also present.
let roots: Vec<_> =
roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect();
for root in &roots {
let module = (*root).clone();
let mut cache = cache.lock();
for root in roots {
if let Some(cached) = cache.roots.get(root) {
paths.extend(cached.paths.iter().cloned());
result.incomplete = result.incomplete.or(cached.incomplete);
continue;
}
let mut found = CachedPaths { paths: Vec::new(), incomplete: None };
let module = root.clone();
let walker = ignore::WalkBuilder::new(root)
.follow_links(false)
.filter_entry(move |entry| {
@@ -324,21 +418,26 @@ fn configuration_files(
if cancel.is_cancelled() {
return result;
}
if visited >= 50_000 || paths.len() >= MAX_FILES {
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
if visited >= 50_000 || found.paths.len() >= MAX_FILES {
found.incomplete = Some(ReferencesIncomplete::TooManyFiles);
break;
}
match entry {
Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => {
paths.insert(entry.into_path());
found.paths.push(entry.into_path());
}
Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
Err(_) => found.incomplete = Some(ReferencesIncomplete::FilesSkipped),
_ => {}
}
}
paths.extend(found.paths.iter().cloned());
result.incomplete = result.incomplete.or(found.incomplete);
if cache.roots.len() >= MAX_FILES {
cache.roots.clear();
}
cache.roots.insert(root.clone(), found);
}
paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned());
let mut cache = cache.lock();
if cache.files.len() > MAX_FILES {
cache.files.clear();
}
+40 -3
View File
@@ -2314,6 +2314,9 @@ impl LspManager {
/// Tells running servers that files or folders moved (`moves`: from → to, after the move).
pub fn did_rename_files(&self, moves: &[(PathBuf, PathBuf)]) {
if !moves.is_empty() {
self.configuration_cache.lock().invalidate_paths();
}
let moves: Vec<(PathBuf, PathBuf, bool)> =
moves.iter().map(|(f, t)| (f.clone(), t.clone(), t.is_dir())).collect();
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
@@ -2334,6 +2337,13 @@ impl LspManager {
/// into `.venv` or a `go.mod` rewritten by `go get` is picked up without a restart. Other
/// servers hear of their languages' files under their root; without it jdtls's incremental
/// build never sees them, so a launch runs stale classes.
///
/// Creates and changes for documents synced on a server's current instance are left out.
/// Their text reaches the server through `didOpen`/`didChange`, including clean-buffer
/// reloads. A server that re-reads the file on the watched change (csharp-ls) would apply
/// the reload and then the `didChange` computed against the pre-reload copy, so it would
/// keep analysing content that diverges from both the disk and the editor (#1780).
/// Deletions still reach it: document sync does not describe workspace membership.
pub fn watched_files_changed(
&self,
created: &[PathBuf],
@@ -2342,20 +2352,47 @@ impl LspManager {
excluded_dirs: &[PathBuf],
) {
use file_watch::Change;
if !created.is_empty() || !removed.is_empty() || !excluded_dirs.is_empty() {
self.configuration_cache.lock().invalidate_paths();
}
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
let open: HashMap<(ide_core::ServerId, u64), HashSet<PathBuf>> = {
let docs = self.docs.lock();
let mut open: HashMap<_, HashSet<_>> = HashMap::new();
for d in docs.values() {
for a in &d.servers {
if let Some(generation) = a.opened_gen {
open.entry((a.server.id, generation))
.or_default()
.extend([d.path.clone(), d.canonical.clone()]);
}
}
}
open
};
let canonical: HashMap<_, _> =
created.iter().chain(modified).filter_map(|p| dunce::canonicalize(p).ok().map(|c| (p, c))).collect();
for s in servers {
let Some(run) = s.running() else { continue };
let open = open.get(&(s.id, run.generation)).filter(|_| run.caps.read().sync != SyncKind::None);
let send = |p: &PathBuf, change: Change| {
change == Change::Deleted
|| !open
.is_some_and(|paths| paths.contains(p) || canonical.get(p).is_some_and(|c| paths.contains(c)))
};
let all = [(created, Change::Created), (modified, Change::Changed), (removed, Change::Deleted)]
.into_iter()
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
let watchers = run.watchers.read();
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
let languages = s.languages();
all.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
.collect()
all.filter(|(p, change)| {
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
})
.collect()
} else {
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
.filter(|(p, change)| watchers.wants(&s.root, p, *change))
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
.collect()
};
drop(watchers);
+30 -3
View File
@@ -6,8 +6,8 @@
//! other project files are silently missing. So, as a word index would, the
//! project files of the server's languages that contain the symbol's name as a
//! whole word (found through the host's [`CandidateFn`]) and that the first
//! answer did not cover are opened on the server for a second request, then
//! closed again. Diagnostics the server publishes for them are dropped.
//! answer and the server's analysis scope did not cover are opened for a second
//! request, then closed again. Diagnostics the server publishes for them are dropped.
use crate::server::{Running, Server};
use crate::text::DocText;
@@ -29,6 +29,8 @@ pub const MAX_CANDIDATES: usize = 300;
const MAX_CANDIDATE_BYTES: u64 = 1 << 20;
/// How long a search that found nothing waits for a server to finish indexing.
const INDEX_WAIT: Duration = Duration::from_secs(10);
/// Optional server command: candidate URIs in, already analysed candidate URIs out.
const COVERED_FILES: &str = "openidea.workspace.coveredFiles";
/// Project files that may mention a symbol.
#[derive(Clone, Debug)]
@@ -103,7 +105,7 @@ impl LspManager {
}
/// Runs `query` on `t`; then again with the project files that mention the
/// symbol at `pos` and are not in `covered(first)` opened on the server.
/// symbol at `pos` outside `covered(first)` and the server's analysis scope opened.
/// A first answer that is empty while the server indexes is retried once
/// indexing ends.
pub(crate) async fn widened<R, F, Fut>(
@@ -146,6 +148,10 @@ impl LspManager {
}
let known: HashSet<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
let files: Vec<PathBuf> = candidates.files.into_iter().filter(|f| !known.contains(f)).collect();
let files = self.files_beyond_scope(t, files, cancel).await;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if files.is_empty() {
return Ok(Widened { first, second: None, incomplete });
}
@@ -174,6 +180,27 @@ impl LspManager {
}
}
async fn files_beyond_scope(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> Vec<PathBuf> {
if files.is_empty() || !t.caps().commands().iter().any(|c| c == COVERED_FILES) {
return files;
}
let uris: Vec<String> = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect();
let params = json!({ "command": COVERED_FILES, "arguments": [uris] });
// An unsupported, failed or malformed scope answer must not remove candidates.
let covered: IdeResult<Vec<String>> =
self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
match covered {
Ok(covered) => {
let covered: HashSet<_> = covered.into_iter().collect();
files.into_iter().zip(uris).filter_map(|(f, uri)| (!covered.contains(&uri)).then_some(f)).collect()
}
Err(e) => {
tracing::debug!("{}: analysis scope unavailable: {}", t.server.name, e.message);
files
}
}
}
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
/// also the declarations containing them, as incoming calls.
+28
View File
@@ -0,0 +1,28 @@
package demo;
@lombok.Data
@lombok.Builder
class User {
private String name;
private int age;
}
@lombok.Getter
@lombok.Setter
@lombok.RequiredArgsConstructor
class Profile {
private final String id;
private String name;
}
@lombok.extern.java.Log
class Logged {
void write() { log.info("hello"); }
}
class PlainUser {
private String name;
PlainUser(String name) { this.name = name; }
String getName() { return name; }
void setName(String name) { this.name = name; }
}
+491 -1
View File
@@ -422,7 +422,8 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
// `a.mk` is open on the server: its changes reach it through document sync, not here.
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("changed.mk"), h.root.join("old.mk"));
// Other languages and files outside the server's root are left out.
let other = h.root.join("notes.txt");
let outside = tempfile::tempdir().unwrap().path().join("far.mk");
@@ -448,6 +449,197 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
h.mgr.shutdown_all().await;
}
/// An external change to a document open on the server's current instance reaches it only
/// through the reload's `didChange`: a watched-file change for it too would make a server
/// that re-reads the file on one (csharp-ls) apply the reload and then the diff computed
/// against the pre-reload copy, so it would keep analysing stale content (#1780). A closed
/// file's change is watched as before, and the file is watched again once its document closes.
#[tokio::test(flavor = "multi_thread")]
async fn documents_open_on_a_server_are_left_out_of_watched_file_changes() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
eventually(|| !s.notified("textDocument/didOpen").is_empty()).await;
// The open document and a closed file changed outside the editor.
let closed = h.root.join("b.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
// The editor reloads the open buffer from disk; the server hears the new text.
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "new")], &|| "new".to_owned());
eventually(|| s.docs.lock().get(uri_of(&path).as_str()).map(String::as_str) == Some("new")).await;
// Once the document closes, the file is watched again.
h.mgr.did_close(doc);
h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
async fn watch_all(s: &MockState) {
s.peer()
.request::<_, Value>(
"client/registerCapability",
json!({ "registrations": [{ "id": "all", "method": "workspace/didChangeWatchedFiles",
"registerOptions": { "watchers": [{ "globPattern": "**/*" }] } }] }),
None,
)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_keep_deletions_and_renames() {
for registered in [false, true] {
let h = harness(&[("mock", incremental_caps())]);
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
if registered {
watch_all(&s).await;
}
let closed = h.root.join("closed.mk");
// An atomic replacement is reported as created and still uses document sync.
h.mgr.watched_files_changed(std::slice::from_ref(&path), &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
let renamed = h.root.join("renamed.mk");
std::fs::rename(&path, &renamed).unwrap();
h.mgr.watched_files_changed(std::slice::from_ref(&renamed), &[], std::slice::from_ref(&path), &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([
{ "uri": uri_of(&renamed), "type": 1 },
{ "uri": uri_of(&path), "type": 3 }
])
);
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_resolve_path_aliases() {
for registered in [false, true] {
let h = harness(&[("mock", incremental_caps())]);
let (_, path) = h.open("Mixed.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
if registered {
watch_all(&s).await;
}
let mut aliases = vec![path.clone()];
let case_alias = h.root.join("mixed.mk");
if dunce::canonicalize(&case_alias).ok() == Some(path.clone()) {
aliases.push(case_alias);
}
#[cfg(unix)]
{
let link = h.root.join("alias.mk");
std::os::unix::fs::symlink(&path, &link).unwrap();
aliases.push(link);
}
let closed = h.root.join("closed.mk");
aliases.push(closed.clone());
h.mgr.watched_files_changed(&[], &aliases, &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_are_scoped_to_the_attached_server() {
let h = harness(&[("mock", incremental_caps()), ("other", incremental_caps())]);
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "mock").cloned().collect()));
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "other").cloned().collect()));
h.open("b.mk", "other");
h.wait_status("other", ServerStatus::Ready).await;
let first = h.launcher.state("mock");
let second = h.launcher.state("other");
watch_all(&first).await;
watch_all(&second).await;
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| {
!first.notified("workspace/didChangeWatchedFiles").is_empty()
&& !second.notified("workspace/didChangeWatchedFiles").is_empty()
})
.await;
assert_eq!(
first.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
assert_eq!(
second.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([
{ "uri": uri_of(&path), "type": 2 }, { "uri": uri_of(&closed), "type": 2 }
])
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_resume_on_a_restart_without_open_documents() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.did_close(doc);
h.mgr.restart(id).await.unwrap();
h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn servers_without_document_sync_keep_open_document_watches() {
let h = harness(&[("mock", json!({ "textDocumentSync": 0 }))]);
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }, { "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
/// A server that registers file watchers hears of what they match, inside excluded folders
/// too (`uv add` into `.venv`, `go get` rewriting `go.mod`); folders outside the project
/// that relative patterns name are reported for watching; unregistered, it falls back to
@@ -2930,6 +3122,112 @@ fn scope_harness() -> (Harness, Arc<MockState>) {
(h, s)
}
#[tokio::test(flavor = "multi_thread")]
async fn indexed_sources_are_not_widened_but_outside_callers_are() {
for classified in [false, true] {
for outside in [false, true] {
let mut caps = incremental_caps();
caps["executeCommandProvider"]["commands"] = if classified {
json!(["openidea.workspace.coveredFiles", "openidea.java.usages"])
} else {
json!(["openidea.workspace.coveredFiles"])
};
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let caller = h.root.join("Caller.java");
let decoy = h.root.join("Unrelated.java");
let extra = h.root.join("Outside.java");
std::fs::write(&caller, "target();").unwrap();
std::fs::write(&decoy, "target").unwrap();
if outside {
std::fs::write(&extra, "target();").unwrap();
}
let covered = vec![uri_of(&caller), uri_of(&decoy)];
let indexed = uri_of(&caller);
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if method == "workspace/executeCommand" && params["command"] == "openidea.workspace.coveredFiles" {
return Some(json!(covered));
}
let model = MockState::default();
*model.docs.lock() = s.docs.lock().clone();
model.docs.lock().insert(indexed.clone(), "target();".into());
let responder = open_files_only(vec![]);
if method == "workspace/executeCommand" && params["command"] == "openidea.java.usages" {
let refs = responder(&model, "textDocument/references", params).unwrap();
return Some(Value::Array(
refs.as_array()
.unwrap()
.iter()
.map(|location| json!({ "location": location, "kind": "unknown", "access": "read" }))
.collect(),
));
}
responder(&model, method, params)
}));
grep_candidates(&h);
let (doc, _) = h.open("Target.java", "def target\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let refs = h
.mgr
.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), None)
.await
.unwrap();
assert!(refs.locations.iter().any(|l| l.path == caller));
assert_eq!(refs.locations.iter().any(|l| l.path == extra), outside);
assert_eq!(refs.incomplete, None);
let calls = h
.mgr
.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default())
.await
.unwrap();
assert!(calls.items.iter().any(|i| i.path == caller));
assert_eq!(calls.items.iter().any(|i| i.path == extra), outside);
assert_eq!(calls.incomplete, None);
let rounds = if outside { 2 } else { 1 };
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), rounds);
if classified {
assert_eq!(
s.requested("workspace/executeCommand")
.iter()
.filter(|p| p["command"] == "openidea.java.usages")
.count(),
rounds
);
assert_eq!(refs.classified.len(), refs.locations.len());
} else {
assert_eq!(s.requested("textDocument/references").len(), rounds);
}
eventually(|| s.docs.lock().len() == 1).await;
let opened = uris(&s.notified("textDocument/didOpen"));
assert!(!opened.contains(&uri_of(&caller)));
assert!(!opened.contains(&uri_of(&decoy)));
assert_eq!(opened.contains(&uri_of(&extra)), outside);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() {
for response in [json!(null), json!([false]), json!([])] {
let mut caps = incremental_caps();
caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]);
let h = harness(&[("mock", caps)]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(open_files_only(vec![]));
s.respond("workspace/executeCommand", response);
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
files: vec![q.root.join("b.mk")],
limit_hit: true,
}));
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles));
assert_eq!(s.requested("textDocument/references").len(), 2);
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), 2);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn references_open_unopened_files_that_mention_the_name() {
let (h, s) = scope_harness();
@@ -4195,6 +4493,198 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
assert_eq!(s.requested("textDocument/definition").len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_lands_in_spring_xml_and_properties() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let s = h.launcher.state("jdtls");
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let resources = h.root.join("src/main/resources");
std::fs::create_dir_all(&resources).unwrap();
std::fs::write(
resources.join("context.xml"),
r#"<beans><util:properties id="db"><prop key="db.user">sa</prop></util:properties></beans>"#,
)
.unwrap();
std::fs::write(h.root.join("db.properties"), "db.user=root\n").unwrap();
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// The Java extension is not needed: the declarations come from the XML
// wiring and the properties file, and the open YAML never matches itself.
assert_eq!(locations.len(), 2, "{locations:?}");
assert!(locations.iter().any(|l| l.path.to_path_buf() == resources.join("context.xml")));
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("db.properties")));
assert!(s.requested("workspace/executeCommand").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholders_use_java_module_roots_and_utf16_columns() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(
h.root.join("pom.xml"),
"<project><packaging>pom</packaging><modules><module>child</module></modules></project>",
)
.unwrap();
let child = h.root.join("child");
let resources = child.join("src/main/resources");
std::fs::create_dir_all(&resources).unwrap();
std::fs::write(child.join("pom.xml"), "<project/>").unwrap();
let definition = resources.join("application.properties");
std::fs::write(&definition, "db.user=sa\n").unwrap();
let sibling = h.root.join("sibling");
std::fs::create_dir(&sibling).unwrap();
std::fs::write(sibling.join("pom.xml"), "<project/>").unwrap();
std::fs::write(sibling.join("application.properties"), "db.user=wrong\n").unwrap();
h.launcher.state("jdtls").respond("workspace/executeCommand", json!({
"locations": [], "keys": [], "roots": [uri_of(&h.root), uri_of(&child), uri_of(&child.join("src/main/java"))]
}));
for (file, language, text) in [
("references.properties", "properties", "message=你好😀 ${db.user}"),
("references.yml", "yaml", "message: 你好😀 ${db.user}"),
("context.xml", "xml", "<bean value=\"你好😀 ${db.user}\"/>"),
] {
let doc = DocId::next();
h.mgr.did_open(doc, &h.root.join(file), &LanguageId::new(language), 1, text);
let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32;
let locations = h.mgr.goto(doc, Pos::new(0, col + 2), GotoKind::Definition).await.unwrap();
assert_eq!(locations.len(), 1, "{file}: {locations:?}");
assert_eq!(locations[0].path.to_path_buf(), definition);
assert_eq!(locations[0].range.start, Pos::new(0, 0));
assert_eq!(locations[0].range.end, Pos::new(0, 7));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_file_list_refreshes_on_watched_changes_and_renames() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let child = h.root.join("child");
std::fs::create_dir(&child).unwrap();
std::fs::write(child.join("pom.xml"), "<project/>").unwrap();
let file = child.join("application.properties");
std::fs::write(&file, "db.user=sa\n").unwrap();
let doc = DocId::next();
h.mgr.did_open(doc, &child.join("reference.yml"), &LanguageId::new("yaml"), 1, "message: ${db.user}");
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
let profile = child.join("application-dev.properties");
std::fs::write(&profile, "db.user=dev\n").unwrap();
// No notification yet: the existing file list is reused.
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
h.mgr.watched_files_changed(std::slice::from_ref(&profile), &[], &[], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2);
std::fs::write(&profile, "db.user=development\n").unwrap();
h.mgr.watched_files_changed(&[], std::slice::from_ref(&profile), &[], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2);
let renamed = child.join("application-test.properties");
std::fs::rename(&profile, &renamed).unwrap();
h.mgr.did_rename_files(&[(profile, renamed.clone())]);
let locations = h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap();
assert_eq!(locations.len(), 2);
assert!(locations.iter().any(|l| l.path.to_path_buf() == renamed));
std::fs::remove_file(&renamed).unwrap();
h.mgr.watched_files_changed(&[], &[], &[renamed], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_merges_java_members_with_xml_declarations() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Db.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}],
"keys": [{"name": "db.user", "relaxed": true}], "roots": []
}),
);
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// A bound Java member and the XML declaration are both candidates.
assert_eq!(locations.len(), 2, "{locations:?}");
assert!(locations.iter().any(|l| l.range.start == Pos::new(1, 4)));
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml")));
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_survives_a_failing_java_extension() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).unwrap();
let s = h.launcher.state("jdtls");
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into()));
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// The XML declaration still answers when the Java extension fails.
assert_eq!(locations.len(), 1, "{locations:?}");
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml")));
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_key_definition_stays_java_only() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="spring.datasource.username">sa</prop></beans>"#)
.unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Settings.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}],
"keys": [{"name": "spring.datasource.username", "relaxed": true}], "roots": []
}),
);
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: sa\n");
let locations = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Definition).await.unwrap();
// A key keeps the Java-only definition path; the XML declaration is not a candidate.
assert_eq!(locations.len(), 1, "{locations:?}");
assert_eq!(locations[0].range.start, Pos::new(2, 4));
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "spring.datasource.username");
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_definition_on_an_xml_property_asks_java() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Db.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}],
"keys": [{"name": "db.user", "relaxed": true}], "roots": []
}),
);
let xml = h.root.join("context.xml");
let doc = DocId::next();
h.mgr.did_open(doc, &xml, &LanguageId::new("xml"), 1, "<beans><prop key=\"db.user\">sa</prop></beans>\n");
let locations = h.mgr.goto(doc, Pos::new(0, 19), GotoKind::Definition).await.unwrap();
// A Spring XML declaration is a configuration site: it navigates to the
// binding member through the Java index.
assert_eq!(locations.len(), 1, "{locations:?}");
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "db.user");
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
let mut caps = incremental_caps();
+140
View File
@@ -112,6 +112,146 @@ async fn kmp_lsp_java_end_to_end() {
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn java_searches_skip_indexed_sources_but_keep_exclusions_outside_scope() {
use ide_lsp::types::HierarchyDirection;
use serde_json::json;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
std::fs::create_dir_all(root.join("src/demo/excluded")).unwrap();
std::fs::write(root.join(".project"), "<projectDescription><name>search-scope</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>").unwrap();
std::fs::write(root.join(".classpath"), "<classpath><classpathentry kind=\"src\" path=\"src\" excluding=\"demo/excluded/**\"/><classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/><classpathentry kind=\"output\" path=\"bin\"/></classpath>").unwrap();
let source = "package demo; public class Target { public static void target() {} }";
let path = root.join("src/demo/Target.java");
let caller = root.join("src/demo/Caller.java");
let decoy = root.join("src/demo/Decoy.java");
let excluded = root.join("src/demo/excluded/Excluded.java");
let outside = root.join("Outside.java");
std::fs::write(&path, source).unwrap();
std::fs::write(&caller, "package demo; class Caller { void call() { Target.target(); } }").unwrap();
// A source file mentioning the name without a usage, too large for transient opening.
std::fs::write(&decoy, format!("package demo; class Decoy {{ void target() {{}} }}\n/*{}*/", " ".repeat(1 << 20)))
.unwrap();
std::fs::write(&excluded, "package demo.excluded; class Excluded {}").unwrap();
std::fs::write(&outside, "class Outside {}").unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
java.language_servers.retain(|s| s.name == "jdtls");
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend(["-data".into(), dir.path().join("metadata").to_string_lossy().into_owned()]);
let bundle = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/java-usages/openidea-java-usages.jar");
server
.initialization_options
.as_mut()
.unwrap()
.as_table_mut()
.unwrap()
.insert("bundles".into(), toml::Value::Array(vec![toml::Value::String(bundle.to_string_lossy().into_owned())]));
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
mgr.java_build(&path, &Default::default()).await.unwrap();
let uri = ide_lsp::path_to_uri_string;
let covered = retry("imported source scope", Duration::from_secs(120), || async {
mgr.jdtls_command(
&path,
"openidea.workspace.coveredFiles",
vec![json!([uri(&caller), uri(&decoy), uri(&excluded), uri(&outside),])],
)
.await
.ok()
.filter(|v| v.as_array().is_some_and(|a| !a.is_empty()))
})
.await;
assert_eq!(covered, json!([uri(&caller), uri(&decoy)]));
mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: vec![caller.clone(), decoy.clone()],
limit_hit: false,
}));
let at = Pos::new(0, source.find("target()").unwrap() as u32);
let refs = mgr.references_streaming(doc, at, false, true, false, Default::default(), None).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert!(refs.locations[0].path.ends_with("Caller.java"));
assert_eq!(refs.incomplete, None, "the indexed decoy must not be transiently opened");
let calls = mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, Default::default()).await.unwrap();
assert_eq!(calls.items.len(), 1);
assert!(calls.items[0].path.ends_with("Caller.java"));
assert_eq!(calls.incomplete, None);
mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn lombok_generated_members_appear_in_document_symbols() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let source = include_str!("fixtures/LombokSymbols.java");
let path = root.join("src/demo/LombokSymbols.java");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, source).unwrap();
let agent = dunce::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/lombok/lombok-1.18.48-openidea.3.jar"),
)
.unwrap();
std::fs::write(
root.join(".project"),
"<projectDescription><name>lombok-symbols</name><buildSpec><buildCommand>\
<name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures>\
<nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>",
)
.unwrap();
let library = agent.to_string_lossy().replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
std::fs::write(
root.join(".classpath"),
format!(
"<classpath><classpathentry kind=\"src\" path=\"src\"/>\
<classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/>\
<classpathentry kind=\"lib\" path=\"{library}\"/>\
<classpathentry kind=\"output\" path=\"bin\"/></classpath>"
),
)
.unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
java.language_servers.retain(|s| s.name == "jdtls");
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend([
"-data".into(),
root.join("workspace").to_string_lossy().into_owned(),
format!("--jvm-arg=-javaagent:{}", agent.display()),
]);
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
let expected: &[(&str, &[&str])] = &[
("User", &["getName()", "setName(String)", "getAge()", "setAge(int)", "User(String, int)", "builder()"]),
("UserBuilder", &["name(String)", "age(int)", "build()"]),
("Profile", &["Profile(String)", "getId()", "getName()", "setName(String)"]),
("Logged", &["log", "write()"]),
("PlainUser", &["PlainUser(String)", "getName()", "setName(String)"]),
];
fn has_members(items: &[ide_core::OutlineItem], owner: &str, names: &[&str]) -> bool {
items.iter().any(|item| {
(item.name == owner && names.iter().all(|n| item.children.iter().any(|c| c.name == *n)))
|| has_members(&item.children, owner, names)
})
}
retry("Lombok document symbols", Duration::from_secs(120), || async {
let items = mgr.document_symbols(doc).await.ok().flatten()?;
expected.iter().all(|(owner, names)| has_members(&items, owner, names)).then_some(())
})
.await;
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
+81 -13
View File
@@ -13,6 +13,7 @@
//! idle reclaim ends it.
use crate::runtime::{CallCancel, CallError, Runtime};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
@@ -20,6 +21,17 @@ use std::time::{Duration, Instant};
type Job = Box<dyn FnOnce(&mut Option<Runtime>) + Send>;
fn catch_call<R>(f: impl FnOnce() -> Result<R, CallError>) -> Result<R, CallError> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|panic| {
let message = panic
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("unknown panic");
Err(CallError::Trap(format!("plugin actor panicked: {message}")))
})
}
/// Why an [`Actor::call`] produced no result.
#[derive(Debug)]
pub enum ActorError {
@@ -85,15 +97,24 @@ impl Actor {
let cancel = runtime.cancel_handle();
let alive = Arc::new(AtomicBool::new(true));
let busy = Arc::new(AtomicBool::new(false));
let crash = Arc::new(Mutex::new(None));
let last_used = Arc::new(AtomicU64::new(0));
let epoch = Instant::now();
let thread = {
let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone());
let (alive, busy, last_used, crash) = (alive.clone(), busy.clone(), last_used.clone(), crash.clone());
std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || {
let mut slot = Some(runtime);
while let Ok(job) = rx.recv() {
busy.store(true, Ordering::Release);
job(&mut slot);
// A completion callback may panic too; never leave a dead thread marked alive.
if let Err(CallError::Trap(error)) = catch_call(|| {
job(&mut slot);
Ok(())
}) {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(error);
alive.store(false, Ordering::Release);
slot = None;
}
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
busy.store(false, Ordering::Release);
if slot.is_none() {
@@ -113,7 +134,7 @@ impl Actor {
thread: Mutex::new(Some(thread)),
cancel,
alive,
crash: Arc::default(),
crash,
busy,
last_used,
epoch,
@@ -148,7 +169,7 @@ impl Actor {
return;
}
let Some(rt) = slot.as_mut() else { return };
let outcome = f(rt);
let outcome = catch_call(|| f(rt));
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
// Before the reply: the caller sees the actor dead.
@@ -174,21 +195,59 @@ impl Actor {
cancel: &ide_core::CancellationToken,
grace: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, None, f)
}
/// A cancellable editor call: queued work expires after `soft`, and a
/// successful answer produced later is dropped. Running work still has
/// its runtime hard deadline and can be cancelled by further typing.
pub fn call_edit_cancellable<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
soft: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), f)
}
fn call_cancellable_before<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
deadline: Option<Instant>,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
let (reply, result) = mpsc::sync_channel(1);
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let skip = cancel.clone();
let running = Arc::new(Mutex::new(false));
let in_job = running.clone();
let job: Job = Box::new(move |slot| {
if skip.is_cancelled() {
return;
}
let Some(rt) = slot.as_mut() else { return };
let outcome = f(rt);
{
let mut running = in_job.lock().unwrap_or_else(|e| e.into_inner());
if skip.is_cancelled() {
return;
}
if deadline.is_some_and(|t| Instant::now() >= t) {
let _ = reply.send(Err(ActorError::SoftTimeout));
return;
}
*running = true;
}
let outcome = catch_call(|| f(rt));
*in_job.lock().unwrap_or_else(|e| e.into_inner()) = false;
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
*slot = None;
}
let outcome = match outcome {
Ok(_) if deadline.is_some_and(|t| Instant::now() > t) => Err(ActorError::SoftTimeout),
other => other.map_err(ActorError::Call),
};
let _ = reply.send(outcome);
});
{
@@ -203,25 +262,34 @@ impl Actor {
drop(outcome);
return Err(ActorError::Cancelled);
}
Ok(outcome) => return outcome.map_err(ActorError::Call),
Ok(outcome) => return outcome,
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped });
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if !cancel.is_cancelled() {
if deadline.is_some_and(|t| Instant::now() >= t) && !*running.lock().unwrap_or_else(|e| e.into_inner())
{
return Err(ActorError::SoftTimeout);
}
continue;
}
match cancelled_at {
None => {
cancelled_at = Some(Instant::now());
if self.is_busy() {
self.cancel.cancel_soft();
let running = running.lock().unwrap_or_else(|e| e.into_inner());
if !*running {
return Err(ActorError::Cancelled);
}
self.cancel.cancel_soft();
}
Some(at) if at.elapsed() >= grace => {
// Did not return in time: interrupt it (the instance is rebuilt).
self.cancel();
let running = running.lock().unwrap_or_else(|e| e.into_inner());
if *running {
self.cancel();
}
return Err(ActorError::Cancelled);
}
Some(_) => {}
@@ -239,7 +307,7 @@ impl Actor {
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let job: Job = Box::new(move |slot| {
let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) };
let outcome = f(rt);
let outcome = catch_call(|| f(rt));
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
+92 -8
View File
@@ -8,8 +8,9 @@ use crate::types::{
PluginHover, PluginInlayHint, ProviderDocument, SemanticToken,
};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessChunk,
ProcessCommand, ProcessOutput, Prompt, RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken,
WorkbenchEvent,
};
use crate::types::{
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
@@ -121,6 +122,19 @@ pub trait HostApi: Send + Sync {
.filter(|root| real.starts_with(crate::resolve_real(root)))
.max_by_key(|root| root.components().count())
}
/// Searches the current in-memory index of an open project.
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<crate::SymbolInfo>, HostError> {
let _ = (root, query, limit, cancel, deadline);
Err(HostError::NotFound("no project index is available".into()))
}
/// Unsaved text of an open editor's file.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Version and text of the open document at `path`.
@@ -167,11 +181,11 @@ pub trait HostApi: Send + Sync {
/// Performs `request` (its host is granted), filling in secret placeholders.
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
fn secret_is_set(&self, name: &str) -> bool;
/// Runs the approved file of `process:<program>`.
/// Runs the approved file of `process:<program>` with `cmd.env` added to
/// the inherited environment, and waits.
fn run_process(
&self,
program: &str,
args: Vec<String>,
cmd: ProcessCommand,
cwd: Option<&Path>,
stdin: Option<String>,
cancel: &CancellationToken,
@@ -182,6 +196,35 @@ pub trait HostApi: Send + Sync {
/// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`.
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError>;
fn unsubscribe(&self, kind: &str);
/// Starts `cmd` and returns the handle to it without waiting.
fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result<u64, HostError>;
/// What process `id` printed since the last call, each stream at most
/// `max` bytes (the rest waits for the next call), and its status once it
/// ended.
fn poll_process(&self, id: u64, max: usize) -> Result<ProcessChunk, HostError>;
/// Writes `text` to the stdin of process `id`.
fn write_process(
&self,
id: u64,
text: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError>;
/// Closes the stdin of process `id`.
fn close_process_stdin(&self, id: u64) -> Result<(), HostError>;
/// Ends process `id`; not an error if it already ended.
fn kill_process(&self, id: u64) -> Result<(), HostError>;
/// Forgets the handle and ends its process if it still runs.
fn release_process(&self, id: u64);
/// Waits up to `timeout` for process `id` to end (`ZERO` only checks) and
/// returns what it printed meanwhile with its status once it ended.
fn wait_process(
&self,
id: u64,
max: usize,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<ProcessChunk, HostError>;
/// Starts a progress indicator; cancelling it from the UI cancels `cancel`.
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64;
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>);
@@ -453,8 +496,10 @@ pub struct StoreData {
pub(crate) granted: Vec<Capability>,
pub(crate) host: Arc<dyn HostApi>,
pub(crate) invocation: Invocation,
/// The running call is a command or an event, so it may ask the user.
/// The running call is a command or an event.
pub(crate) interactive: bool,
/// Editor window for pick, pick-many, input and confirm during action resolution.
pub(crate) action_prompt_client: Option<String>,
/// Tasks begun by the running call and not ended yet.
pub(crate) open_tasks: Vec<u64>,
pub(crate) last_report: Option<Instant>,
@@ -587,6 +632,7 @@ struct Exports {
views: Option<wit_view::Guest>,
semantic: Option<wit_sem::Guest>,
details: Option<wit_detail::Guest>,
diagnostics: Option<crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider::Guest>,
hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
inlay: Option<wit_inlay::Guest>,
@@ -643,6 +689,7 @@ impl Runtime {
host: setup.host,
invocation: Invocation::default(),
interactive: false,
action_prompt_client: None,
open_tasks: Vec::new(),
last_report: None,
};
@@ -673,6 +720,8 @@ impl Runtime {
let view_indices = wit_view::GuestIndices::new(&pre).ok();
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
let detail_indices = wit_detail::GuestIndices::new(&pre).ok();
use crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider as wit_diag;
let diagnostics_indices = wit_diag::GuestIndices::new(&pre).ok();
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
@@ -707,6 +756,10 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let diagnostics = match diagnostics_indices {
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let hover = match hover_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
@@ -733,6 +786,7 @@ impl Runtime {
indexer,
semantic,
details,
diagnostics,
hover,
commit_checks,
inlay,
@@ -944,6 +998,7 @@ impl Runtime {
&mut self,
doc: &ProviderDocument,
action: &CodeAction,
invocation: Invocation,
timeout: Duration,
) -> Result<CodeAction, CallError> {
if action.file_action {
@@ -953,13 +1008,20 @@ impl Runtime {
let d = file_action_doc(doc);
let a = file_action_to_wit(action);
let resolved = self.call(Invocation::default(), timeout, |e, store| {
store.data_mut().action_prompt_client = invocation.client;
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))?;
if self.exports.providers.is_none() {
return Err(CallError::missing_export("providers"));
}
let resolved = self.call(Invocation::default(), timeout, |e, store| {
store.data_mut().action_prompt_client = invocation.client;
e.providers.as_ref().expect("checked").call_resolve_code_action(store, &d, &a)
})?;
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
}
@@ -986,7 +1048,7 @@ impl Runtime {
.take(MAX_PROVIDER_ITEMS)
.map(|m| LineMarker {
line: m.line,
icon: m.icon.map(|i| clip(i, 32)),
icon: m.icon.map(|i| clip(i, 256)),
tooltip: clip(m.tooltip, 1024),
target: m.target.map(location_from_wit),
})
@@ -1021,6 +1083,27 @@ impl Runtime {
.collect())
}
pub fn provide_diagnostics(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<ide_core::Diagnostic>, CallError> {
use crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider as w;
if self.exports.diagnostics.is_none() {
return Err(CallError::missing_export("diagnostics-provider"));
}
let d = w::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let items = self.call(Invocation::default(), timeout, |e, store| {
e.diagnostics.as_ref().expect("checked").call_diagnostics(store, &d)
})?;
Ok(crate::wit_host::diagnostics_from_wit(items, &self.store.data().plugin_id))
}
pub fn provide_hover(
&mut self,
doc: &ProviderDocument,
@@ -1281,6 +1364,7 @@ impl Runtime {
let data = self.store.data_mut();
data.invocation = Invocation::default();
data.interactive = false;
data.action_prompt_client = None;
// Tasks don't outlive the call that began them.
for id in std::mem::take(&mut data.open_tasks) {
data.host.task_end(id);
+27
View File
@@ -352,6 +352,24 @@ pub struct ProcessOutput {
pub stderr: String,
}
/// What to run: the approved file of `process:<program>`, its arguments and
/// the environment to add to the inherited one (WIT `process.command`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProcessCommand {
pub program: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
}
/// What a kept-running program printed since the last read, and its exit
/// status once it ended (WIT `process.chunk`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProcessChunk {
pub stdout: String,
pub stderr: String,
pub status: Option<i32>,
}
/// Where `net:` requests may go: the URL's host when it is `https`, or
/// `http` to the loopback interface.
pub fn net_host(url: &str) -> Result<String, String> {
@@ -415,6 +433,15 @@ pub struct Location {
pub range: ide_core::Range,
}
/// A symbol in the current project index. Ranges use UTF-16 columns.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SymbolInfo {
pub name: String,
pub kind: String,
pub container: Option<String>,
pub location: Location,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompletionItem {
pub label: String,
+214 -36
View File
@@ -5,7 +5,8 @@
use crate::runtime::{MAX_TITLE, StoreData, clip};
use crate::sandbox;
use crate::types::{
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, Prompt, StatusItem, net_host,
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, ProcessChunk, ProcessCommand,
Prompt, StatusItem, net_host,
};
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
@@ -23,6 +24,7 @@ pub(crate) mod bindings {
"openidea:plugin/editor.text-editor": super::EditorRes,
"openidea:plugin/invocation.invocation": super::InvocationRes,
"openidea:plugin/tasks.task": super::TaskRes,
"openidea:plugin/process.child": super::ChildRes,
},
});
}
@@ -124,6 +126,19 @@ pub struct ProjectRes {
root: PathBuf,
}
/// A program a plugin started behind a handle (`process.start`); dropping the
/// handle ends it if it still runs.
pub struct ChildRes {
id: u64,
host: std::sync::Arc<dyn crate::HostApi>,
}
impl Drop for ChildRes {
fn drop(&mut self) {
self.host.release_process(self.id);
}
}
/// The invoking window's focused editor.
pub struct EditorRes {
ctx: EditorContext,
@@ -143,6 +158,15 @@ const MAX_FIND: u32 = 10_000;
const MAX_STATUS_TEXT: usize = 128;
const MAX_PICK_ITEMS: usize = 5000;
const MAX_PICK_KEY: usize = 256;
const MAX_ARGS: usize = 256;
const MAX_ARG_BYTES: usize = 4096;
const MAX_ENV: usize = 64;
const MAX_ENV_NAME: usize = 256;
const MAX_ENV_VALUE: usize = 4096;
/// Most output one `poll` / `wait` hands out, per stream.
const MAX_CHUNK: usize = 1 << 20;
/// Most text one `write` puts into a process's stdin.
const MAX_STDIN_BYTES: usize = 1 << 20;
type Fallible<T> = wasmtime::Result<Result<T, wit::Error>>;
@@ -246,8 +270,16 @@ impl StoreData {
/// Asks the user and waits; the wait doesn't count against the call's
/// wall-clock budget.
fn ask(&mut self, prompt: Prompt) -> Result<Option<Vec<String>>, wit::Error> {
self.require_interactive()?;
let client = self.invocation.client.clone();
let client = match (&prompt, &self.action_prompt_client) {
(
Prompt::QuickPick { .. } | Prompt::Input { multiline: false, .. } | Prompt::Confirm { .. },
Some(client),
) => Some(client.clone()),
_ => {
self.require_interactive()?;
self.invocation.client.clone()
}
};
let started = Instant::now();
let answer = self.host.ask(client.as_deref(), prompt, &self.cancel.token());
let waited = started.elapsed();
@@ -279,7 +311,7 @@ impl StoreData {
self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple })
}
/// Dialogs belong to commands and events; rendering and providers have no one to ask.
/// APIs reserved for commands and events exclude code-action resolution.
fn require_interactive(&self) -> Result<(), wit::Error> {
if self.interactive {
return Ok(());
@@ -479,6 +511,39 @@ impl workspace::Host for StoreData {
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
}
fn symbols(
&mut self,
project: Resource<ProjectRes>,
query: String,
limit: u32,
) -> Fallible<Vec<workspace::SymbolInfo>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
if query.len() > 256 {
return Ok(Err(wit::Error::Invalid("symbol query exceeds 256 UTF-8 bytes".into())));
}
let root = &self.table.get(&project)?.root;
Ok(self
.host
.symbols(root, &query, limit.min(1000) as usize, &self.cancel.token(), self.deadline)
.map(|symbols| {
symbols
.into_iter()
.map(|s| workspace::SymbolInfo {
name: s.name,
kind: s.kind,
container: s.container,
location: wit::Location {
path: to_ipc(&s.location.path),
range: range_to_wit(&s.location.range),
},
})
.collect()
})
.map_err(to_wit))
}
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
@@ -857,26 +922,7 @@ impl diagnostics::Host for StoreData {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let source = self.plugin_id.clone();
let items = items
.into_iter()
.take(MAX_DIAGNOSTICS)
.map(|d| ide_core::Diagnostic {
range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)),
severity: match d.severity {
diagnostics::Severity::Error => ide_core::Severity::Error,
diagnostics::Severity::Warning => ide_core::Severity::Warning,
diagnostics::Severity::Info => ide_core::Severity::Info,
diagnostics::Severity::Hint => ide_core::Severity::Hint,
},
message: clip(d.message, 4096),
source: Some(d.source.map_or_else(|| source.clone(), |s| clip(s, 128))),
code: d.code.map(|c| clip(c, 128)),
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
deprecated: d.tags.iter().any(|t| t == "deprecated"),
server: Some(source.clone()),
})
.collect();
let items = diagnostics_from_wit(items, &self.plugin_id);
Ok(self.host.publish_diagnostics(Path::new(&path), items).map_err(to_wit))
}
}
@@ -925,6 +971,45 @@ impl secrets::Host for StoreData {
}
}
impl StoreData {
/// The `command` of `process.run-command` / `process.start`, checked: the
/// program is the one the user approved, the sizes stay in the limits.
fn command(&self, cmd: process::Command) -> Result<ProcessCommand, wit::Error> {
self.require(Capability::Process(cmd.program.clone()))?;
if cmd.args.len() > MAX_ARGS || cmd.args.iter().any(|a| a.len() > MAX_ARG_BYTES) {
return Err(wit::Error::Invalid(format!("at most {MAX_ARGS} arguments of at most {MAX_ARG_BYTES} bytes")));
}
if cmd.env.len() > MAX_ENV {
return Err(wit::Error::Invalid(format!("at most {MAX_ENV} environment variables")));
}
for (name, value) in &cmd.env {
if name.is_empty() || name.len() > MAX_ENV_NAME || name.contains(['=', '\0']) {
return Err(wit::Error::Invalid(format!("{name:?} is not an environment variable name")));
}
if value.len() > MAX_ENV_VALUE || value.contains('\0') {
return Err(wit::Error::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
}
}
Ok(ProcessCommand { program: cmd.program, args: cmd.args, env: cmd.env })
}
/// The project a call runs in, when it named one.
fn cwd_of(&self, cwd: Option<Resource<ProjectRes>>) -> wasmtime::Result<Option<PathBuf>> {
Ok(match &cwd {
Some(p) => Some(self.table.get(p)?.root.clone()),
None => None,
})
}
}
fn to_chunk(chunk: ProcessChunk) -> process::Chunk {
process::Chunk { stdout: chunk.stdout, stderr: chunk.stderr, status: chunk.status }
}
fn to_output(output: crate::types::ProcessOutput) -> process::Output {
process::Output { status: output.status, stdout: output.stdout, stderr: output.stderr }
}
impl process::Host for StoreData {
fn run(
&mut self,
@@ -933,20 +1018,81 @@ impl process::Host for StoreData {
cwd: Option<Resource<ProjectRes>>,
stdin: Option<String>,
) -> Fallible<process::Output> {
if let Err(e) = self.require(Capability::Process(program.clone())) {
return Ok(Err(e));
}
let cwd = match &cwd {
Some(p) => Some(self.table.get(p)?.root.clone()),
None => None,
let cmd = match self.command(process::Command { program, args, env: Vec::new() }) {
Ok(cmd) => cmd,
Err(e) => return Ok(Err(e)),
};
let cwd = self.cwd_of(cwd)?;
let token = self.cancel.token();
let timeout = self.remaining();
Ok(self
.host
.run_process(&program, args, cwd.as_deref(), stdin, &token, timeout)
.map(|o| process::Output { status: o.status, stdout: o.stdout, stderr: o.stderr })
.map_err(to_wit))
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
}
fn run_command(
&mut self,
cmd: process::Command,
cwd: Option<Resource<ProjectRes>>,
stdin: Option<String>,
) -> Fallible<process::Output> {
let cmd = match self.command(cmd) {
Ok(cmd) => cmd,
Err(e) => return Ok(Err(e)),
};
let cwd = self.cwd_of(cwd)?;
let token = self.cancel.token();
let timeout = self.remaining();
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
}
fn start(&mut self, cmd: process::Command, cwd: Option<Resource<ProjectRes>>) -> Fallible<Resource<ChildRes>> {
let cmd = match self.command(cmd) {
Ok(cmd) => cmd,
Err(e) => return Ok(Err(e)),
};
let cwd = self.cwd_of(cwd)?;
let id = match self.host.start_process(cmd, cwd.as_deref(), self.invocation.client.as_deref()) {
Ok(id) => id,
Err(e) => return Ok(Err(to_wit(e))),
};
self.push(ChildRes { id, host: self.host.clone() }).map(Ok)
}
}
impl process::HostChild for StoreData {
fn poll(&mut self, this: Resource<ChildRes>, max_bytes: u32) -> Fallible<process::Chunk> {
let id = self.table.get(&this)?.id;
let max = (max_bytes as usize).min(MAX_CHUNK);
Ok(self.host.poll_process(id, max).map(to_chunk).map_err(to_wit))
}
fn wait(&mut self, this: Resource<ChildRes>, timeout_ms: u32) -> Fallible<process::Chunk> {
let id = self.table.get(&this)?.id;
let timeout = Duration::from_millis(u64::from(timeout_ms)).min(self.remaining());
let token = self.cancel.token();
Ok(self.host.wait_process(id, MAX_CHUNK, timeout, &token).map(to_chunk).map_err(to_wit))
}
fn write(&mut self, this: Resource<ChildRes>, text: String) -> Fallible<()> {
if text.len() > MAX_STDIN_BYTES {
return Ok(Err(wit::Error::Invalid(format!("at most {MAX_STDIN_BYTES} bytes at a time"))));
}
let id = self.table.get(&this)?.id;
Ok(self.host.write_process(id, &text, &self.cancel.token(), self.remaining()).map_err(to_wit))
}
fn close_stdin(&mut self, this: Resource<ChildRes>) -> Fallible<()> {
let id = self.table.get(&this)?.id;
Ok(self.host.close_process_stdin(id).map_err(to_wit))
}
fn kill(&mut self, this: Resource<ChildRes>) -> Fallible<()> {
let id = self.table.get(&this)?.id;
Ok(self.host.kill_process(id).map_err(to_wit))
}
fn drop(&mut self, rep: Resource<ChildRes>) -> wasmtime::Result<()> {
self.table.delete(rep)?;
Ok(())
}
}
@@ -1127,7 +1273,7 @@ fn too_much_to_write(path: &str) -> wit::Error {
}
/// Largest message between a plugin and its view's frame.
const MAX_VIEW_MESSAGE: usize = 1 << 20;
const MAX_VIEW_MESSAGE: usize = 8 << 20;
pub(crate) mod indexing_bindings {
wasmtime::component::bindgen!({
@@ -1232,3 +1378,35 @@ mod project_ui_tests {
assert!(validate_ui_path(&format!("/{}", "x".repeat(8192))).is_err());
}
}
pub(crate) mod diagnostics_bindings {
wasmtime::component::bindgen!({
world: "editor-diagnostics", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
},
});
}
pub(crate) fn diagnostics_from_wit(items: Vec<diagnostics::Diagnostic>, source: &str) -> Vec<ide_core::Diagnostic> {
items
.into_iter()
.take(MAX_DIAGNOSTICS)
.map(|d| ide_core::Diagnostic {
range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)),
severity: match d.severity {
diagnostics::Severity::Error => ide_core::Severity::Error,
diagnostics::Severity::Warning => ide_core::Severity::Warning,
diagnostics::Severity::Info => ide_core::Severity::Info,
diagnostics::Severity::Hint => ide_core::Severity::Hint,
},
message: clip(d.message, 4096),
source: Some(d.source.map_or_else(|| source.to_owned(), |s| clip(s, 128))),
code: d.code.map(|c| clip(c, 128)),
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
deprecated: d.tags.iter().any(|t| t == "deprecated"),
server: Some(source.to_owned()),
})
.collect()
}
+2
View File
@@ -30,7 +30,9 @@ fn components() -> Vec<PathBuf> {
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
Binary file not shown.
@@ -1 +1 @@
95de177eb6e9db39e0a9d5acb86ac1b0f19d91c954590135748774ba83638b53
24726dac5ce264f5d451956def099e0002ec721e4828bc7852ad1dcabcb4bcba
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-detail-views"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-detail-views"
version = "0.1.0"
edition = "2024"
publish = false
# Standalone: not a member of the IDE workspace (different target).
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
833c378fabd0c3ec71e5c372e51049cb4fcd8d58e4992199eaf6090b3c1235d8
@@ -0,0 +1,24 @@
id = "test.detail-views"
name = "Detail views"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-view:tree", "on-view:list", "on-view:services"]
[[contributes.views]]
id = "tree"
title = "Detail Tree"
location = "left"
detail = true
[[contributes.views]]
id = "list"
title = "Detail List"
location = "bottom"
detail = true
[[contributes.views]]
id = "services"
title = "Detail Services"
location = "services"
detail = true
@@ -0,0 +1,64 @@
//! A tree/list detail-provider fixture for host and native UI verification.
use std::collections::BTreeMap;
use openidea_plugin::details::{Details, Pane, Tab};
use openidea_plugin::views::{Body, Button, ListItem, TreeItem, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
#[derive(Default)]
struct DetailViews {
selected: BTreeMap<String, String>,
removed: bool,
revision: u32,
}
impl Plugin for DetailViews {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(id.into()))
}
}
impl Views for DetailViews {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
let keys: &[&str] = if self.removed { &["beta"] } else { &["alpha", "beta"] };
let body = if view == "list" {
Body::List(keys.iter().map(|key| ListItem::new(key, key)).collect())
} else {
Body::Tree(keys.iter().map(|key| TreeItem::new(key, key)).collect())
};
Ok(View::new(body)
.button(Button::new("refresh", "Refresh details"))
.button(Button::new("remove", "Remove alpha")))
}
fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
match event {
ViewEvent::Select(key) => {
self.selected.insert(view.to_owned(), key);
}
ViewEvent::Button(id) => {
if id == "remove" {
self.removed = true;
}
self.revision += 1;
host::refresh_view(view);
}
_ => {}
}
Ok(())
}
}
impl Details for DetailViews {
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
if self.selected.get(view).map(String::as_str) != Some(key) {
return Err(Error::Failed("select must finish before detail".into()));
}
Ok(vec![
Tab::new("info", "Info", Pane::Text(format!("{view}: {key}\nRevision {}", self.revision))),
Tab::new("message", "Summary", Pane::Message(format!("Selected {key}"))),
])
}
}
register_plugin!(DetailViews, views, details);
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-diagnostics"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "openidea-test-diagnostics"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[features]
diagnostics-first = []
diagnostics-last = []
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
@@ -0,0 +1,6 @@
# Diagnostics provider fixture
Build with `plugins/build.sh crates/ide-plugin/tests/fixtures/diagnostics`.
Used by `cargo test -p ide-host --test plugin_diagnostics` for real Wasm dispatch,
UTF-16 ranges, debouncing, cancellation, window/project lifecycle and coexistence
with manually published diagnostics. Not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
f757407f530839b8c43697e310926c5b141231bd8f2781ac92eee3a9de3f0f30
@@ -0,0 +1,23 @@
id = "test.diagnostics"
name = "Diagnostics fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
[capabilities]
required = ["project.read"]
[[contributes.providers]]
kinds = ["diagnostics"]
languages = ["text", "plaintext"]
[[contributes.commands]]
id = "publish"
title = "Publish fixture diagnostic"
[[contributes.commands]]
id = "calls"
title = "Diagnostics fixture call count"
[[contributes.commands]]
id = "wait"
title = "Wait for diagnostics fixture"
+169
View File
@@ -0,0 +1,169 @@
use openidea_plugin::diagnostics::{DiagnosticsProvider, Document};
use openidea_plugin::{Diagnostic, Error, Invocation, Plugin, Range, Severity, host, register_plugin, text};
use std::time::Duration;
#[derive(Default)]
struct Fixture {
calls: u64,
}
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"wait" => {
std::thread::sleep(Duration::from_millis(400));
Ok(Some("finished".into()))
}
"calls" => Ok(Some(self.calls.to_string())),
"publish" => host::publish_diagnostics(
arg.unwrap_or_default(),
&[Diagnostic::new(Range::default(), Severity::Info, "Manual problem")],
)
.map(|()| None),
_ => Err(Error::NotFound(id.into())),
}
}
}
impl DiagnosticsProvider for Fixture {
fn diagnostics(&mut self, doc: &Document) -> Vec<Diagnostic> {
self.calls += 1;
if doc.text == "limits" {
let mut diagnostic = Diagnostic::new(Range::default(), Severity::Hint, "m".repeat(5000));
diagnostic.source = Some("s".repeat(200));
diagnostic.code = Some("c".repeat(200));
diagnostic.unnecessary = true;
diagnostic.deprecated = true;
return vec![diagnostic; 5001];
}
if doc.text.starts_with("slow") {
std::thread::sleep(Duration::from_millis(200));
}
if doc.text.starts_with("spin") {
loop {
std::hint::spin_loop();
}
}
if doc.text.starts_with("panic") {
panic!("diagnostics fixture failed");
}
doc.text
.match_indices("BAD")
.map(|(offset, _)| {
Diagnostic::new(
Range { start: text::position(&doc.text, offset), end: text::position(&doc.text, offset + 3) },
Severity::Warning,
format!("BAD in {} at version {}", doc.language, doc.version),
)
.code("bad-word")
})
.collect()
}
}
impl openidea_plugin::hover::HoverProvider for Fixture {
fn hover(&mut self, _: &Document, _: openidea_plugin::Position) -> Option<openidea_plugin::hover::Hover> {
None
}
}
impl openidea_plugin::inlay_hints::InlayHints for Fixture {
fn inlay_hints(&mut self, _: &Document, _: Range) -> Vec<openidea_plugin::inlay_hints::InlayHint> {
Vec::new()
}
}
impl openidea_plugin::providers::Providers for Fixture {}
impl openidea_plugin::endpoints::EndpointsProvider for Fixture {
fn endpoints(&mut self, _: &Invocation) -> Result<Vec<openidea_plugin::endpoints::Endpoint>, Error> {
Ok(Vec::new())
}
}
impl openidea_plugin::views::Views for Fixture {
fn render(
&mut self,
_: &str,
_: Option<openidea_plugin::views::Viewport>,
_: &Invocation,
) -> Result<openidea_plugin::views::View, Error> {
Ok(openidea_plugin::views::View::message(""))
}
}
impl openidea_plugin::indexing::DocumentIndexer for Fixture {
fn index(&mut self, _: &openidea_plugin::indexing::Document) -> openidea_plugin::indexing::Edges {
openidea_plugin::indexing::Edges::default()
}
}
impl openidea_plugin::syntax::SyntaxProvider for Fixture {
fn highlights(&mut self, _: &str, _: &str) -> Vec<openidea_plugin::syntax::Token> {
Vec::new()
}
}
impl openidea_plugin::semantic_tokens::SemanticTokens for Fixture {
fn semantic_tokens(&mut self, _: &Document) -> Vec<openidea_plugin::semantic_tokens::SemanticToken> {
Vec::new()
}
}
impl openidea_plugin::details::Details for Fixture {
fn detail(&mut self, _: &str, _: &str, _: &Invocation) -> Result<Vec<openidea_plugin::details::Tab>, Error> {
Ok(Vec::new())
}
}
impl openidea_plugin::commit::CommitChecks for Fixture {
fn check(
&mut self,
_: &str,
_: &openidea_plugin::CommitDraft,
_: &Invocation,
) -> Result<Vec<openidea_plugin::commit::CommitProblem>, Error> {
Ok(Vec::new())
}
}
impl openidea_plugin::file_actions::FileActions for Fixture {
fn file_actions(&mut self, _: &Document, _: Range) -> Vec<openidea_plugin::file_actions::FileAction> {
Vec::new()
}
}
#[cfg(feature = "diagnostics-first")]
register_plugin!(
Fixture,
diagnostics,
endpoints,
views,
indexing,
syntax,
semantic_tokens,
hover,
providers,
inlay_hints,
details,
commit_checks,
file_actions
);
#[cfg(all(feature = "diagnostics-last", not(feature = "diagnostics-first")))]
register_plugin!(
Fixture,
file_actions,
commit_checks,
details,
inlay_hints,
providers,
hover,
semantic_tokens,
syntax,
indexing,
views,
endpoints,
diagnostics
);
#[cfg(not(any(feature = "diagnostics-first", feature = "diagnostics-last")))]
register_plugin!(Fixture, diagnostics);
Binary file not shown.
@@ -1 +1 @@
0f9992726659db0400c43192a623ecf97977766e0d7282f99e5ba800fdf247c1
99cba883e8030d7679db366a4b18b8c456afccbcbc949e0d7411adc6411f2e00
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-file-templates"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-file-templates"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
afcf583238dddd20d0d709ce7619fada0e60916f9e3c039569d37dec75354471
@@ -0,0 +1,38 @@
id = "file-templates"
name = "File Templates"
version = "0.1.0"
description = "API 1.3 fixture: exact filenames, variables and inferred packages."
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:ping"]
[[contributes.commands]]
id = "ping"
title = "File Templates: Ping"
[[contributes.fileTemplates]]
id = "dockerignore"
name = "Docker ignore"
extension = "dockerignore"
fileName = ".dockerignore"
path = "templates/dockerignore.txt"
[[contributes.fileTemplates]]
id = "attributes"
name = "Git attributes"
fileName = ".gitattributes"
path = "templates/attributes.txt"
variables = [{ name = "PATTERN", prompt = "File pattern" }, { name = "FILTER", prompt = "Attribute filter" }]
[[contributes.fileTemplates]]
id = "repository"
name = "Java repository"
extension = "java"
path = "templates/repository.txt"
variables = [{ name = "ENTITY", prompt = "Entity class" }, { name = "TABLE", prompt = "Table name" }]
[[contributes.fileTemplates]]
id = "kotlin"
name = "Kotlin entity"
extension = "kt"
path = "templates/kotlin.txt"
@@ -0,0 +1,15 @@
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
#[derive(Default)]
struct FileTemplates;
impl Plugin for FileTemplates {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"ping" => Ok(Some("File templates ready".into())),
_ => Err(Error::NotFound(id.into())),
}
}
}
register_plugin!(FileTemplates);
@@ -0,0 +1 @@
$PATTERN$ filter=$FILTER$
@@ -0,0 +1,2 @@
node_modules/
dist/
@@ -0,0 +1,5 @@
package $PACKAGE$
class $NAME$ {
$END$
}
@@ -0,0 +1,7 @@
package $PACKAGE$;
// Table: $TABLE$
public interface $NAME$ {
$ENTITY$ find();
$END$
}
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-grouped-views"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-grouped-views"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
a1ce41144630be398bb63cffca26005955de0511a17c71dc286e7762b19e9cab
@@ -0,0 +1,37 @@
id = "grouped-views"
name = "Grouped Views"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*", "on-view:*"]
[[contributes.commands]]
id = "show"
title = "Grouped Views: Show"
[[contributes.commands]]
id = "hide"
title = "Grouped Views: Hide"
[[contributes.commands]]
id = "reveal"
title = "Grouped Views: Reveal"
[[contributes.views]]
id = "overview"
title = "Grouped Views"
location = "right"
group = "demo"
when = "plugin.grouped-views.ready"
[[contributes.views]]
id = "files"
title = "Project Files"
location = "right"
group = "demo"
when = "workspaceContains:grouped-views.txt"
[[contributes.views]]
id = "legacy"
title = "Ungrouped View"
location = "left"
@@ -0,0 +1,34 @@
use openidea_plugin::views::{Body, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
#[derive(Default)]
struct GroupedViews;
impl Plugin for GroupedViews {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"show" => host::set_context("ready", true)?,
"hide" => host::set_context("ready", false)?,
"reveal" => host::reveal_view(arg.unwrap_or("overview"))?,
_ => return Err(Error::NotFound(id.into())),
}
Ok(None)
}
}
impl Views for GroupedViews {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
match view {
"overview" => Ok(View::new(Body::Message("Context condition is true".into()))),
"files" => Ok(View::new(Body::Message("Project contains grouped-views.txt".into()))),
"legacy" => Ok(View::new(Body::Message("Ungrouped view keeps its original window id".into()))),
_ => Err(Error::NotFound(view.into())),
}
}
fn handle(&mut self, _view: &str, _event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
Ok(())
}
}
register_plugin!(GroupedViews, views);
Binary file not shown.
@@ -1 +1 @@
7a77d431efd04f0fd5b864cd30216e655b67f126d8a4dd4506dcf805a3454076
bbfc134f2121f301bbbcb0f0d596d7b3cc059455bba5ca78992e8bfa6630ce42
Binary file not shown.
@@ -1 +1 @@
5cf52999f65c757d0eb793934c28ae6d9f453053514c2b4c8058d6e70384eb9a
b7f45420859ce9540e0aae1fd803eee13529ef63d0a2f0b6ba9e43a5958eb457
Binary file not shown.
@@ -1 +1 @@
07839dc01f3d528a81969703271d6c7ad1397b142bdc3aff68d7384249a15818
041aa03873ff1d8133f181e86aab2ad3fda41e46503886754114f3abef3643da
Binary file not shown.
@@ -1 +1 @@
7ef04eaa3f6d506291c89877de26a11b6a27df900c223ce53bf1749523839ff6
edcad61ab8e71cff2297edd9b902359d76ef04446bdfc42aae34420ced4d7f26
Binary file not shown.
@@ -1 +1 @@
bd79d7641d84e919a83ea082b00eca78d4d1427e1e3345fba7d289b06862edde
aad3a25c3522fbff6f48c0c1e9de23b6d0d5361ba6658b744ad1590e9fdbe255
+1
View File
@@ -141,6 +141,7 @@ impl Views for Jobs {
ViewEvent::Button(b) if b == "follow" => return self.follow(),
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
ViewEvent::Message(msg) => return host::post_view_message(VIEW, &msg),
_ => return Ok(()),
}
host::refresh_view(VIEW);
Binary file not shown.
@@ -1 +1 @@
95835b883dec1cf9b053e455533ed0329b6fb1b9270832865b7d8fd05fbaff5a
cfb432ea8943bf9b7e2f3ad3708df35260eebd04b3ac059c7643b378a35cd036
+37
View File
@@ -8,6 +8,7 @@ activation = ["on-command:*"]
[capabilities]
required = ["project.read"]
optional = ["process:git"]
[[contributes.commands]]
id = "echo"
@@ -64,3 +65,39 @@ title = "subscribe"
[[contributes.commands]]
id = "diagnose"
title = "diagnose"
[[contributes.commands]]
id = "child"
title = "child"
[[contributes.commands]]
id = "envrun"
title = "envrun"
[[contributes.commands]]
id = "envstart"
title = "envstart"
[[contributes.commands]]
id = "envdeny-run"
title = "envdeny-run"
[[contributes.commands]]
id = "envdeny-start"
title = "envdeny-start"
[[contributes.commands]]
id = "childkeep"
title = "childkeep"
[[contributes.commands]]
id = "childpoll"
title = "childpoll"
[[contributes.commands]]
id = "childdrop"
title = "childdrop"
[[contributes.commands]]
id = "childtrap"
title = "childtrap"
+186 -4
View File
@@ -20,10 +20,14 @@ use exports::openidea::plugin::providers::{
};
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
use exports::openidea::plugin::{commands, event_handler, lifecycle};
use openidea::plugin::{invocation::Invocation, types};
use openidea::plugin::{invocation::Invocation, process, types};
struct Misbehave;
thread_local! {
static CHILDREN: std::cell::RefCell<Vec<process::Child>> = const { std::cell::RefCell::new(Vec::new()) };
}
fn failed(message: &str) -> types::Error {
types::Error::Failed(message.into())
}
@@ -92,6 +96,73 @@ impl commands::Guest for Misbehave {
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
project.write_file(&arg.unwrap_or_default(), "written inside the project\n").map(|()| None)
}
// API 1.3 `process.start`: a program kept running behind a handle.
"child" => {
let cmd = process::Command {
program: "git".into(),
args: vec!["hash-object".into(), "--stdin".into()],
env: Vec::new(),
};
let child = process::start(&cmd, None)?;
child.write("hello\n")?;
child.close_stdin()?;
let chunk = child.wait(30_000)?;
child.kill()?;
Ok(Some(format!("{} {:?}", chunk.stdout.trim(), chunk.status)))
}
"childkeep" | "childtrap" => {
let cmd = process::Command {
program: "git".into(),
args: vec![
"hash-object".into(),
"--stdin".into(),
format!("--path={}", arg.unwrap_or_else(|| "openidea-process-2027".into())),
],
env: Vec::new(),
};
let child = process::start(&cmd, None)?;
CHILDREN.with(|children| children.borrow_mut().push(child));
if id == "childtrap" {
panic!("trap with a live child");
}
Ok(Some("running".into()))
}
"childpoll" => CHILDREN.with(|children| {
let children = children.borrow();
let child = children.last().ok_or_else(|| failed("no child"))?;
Ok(Some(format!("{:?}", child.poll(100)?.status)))
}),
"childdrop" => {
CHILDREN.with(|children| children.borrow_mut().clear());
Ok(None)
}
"envrun" | "envstart" | "envdeny-run" | "envdeny-start" => {
if arg.as_deref() == Some("legacy") {
return Ok(Some(process::run("git", &["--version".into()], None, None)?.stdout));
}
let env = if id.starts_with("envdeny") {
vec![(arg.unwrap_or_else(|| "PATH".into()), "replacement".into())]
} else {
vec![("OPENIDEA_PROCESS_TEST_VALUE".into(), "added by the plugin".into())]
};
let cmd = process::Command {
program: "git".into(),
args: vec![
"--config-env=openidea.test=OPENIDEA_PROCESS_TEST_VALUE".into(),
"config".into(),
"--get".into(),
"openidea.test".into(),
],
env,
};
if id.ends_with("start") {
let child = process::start(&cmd, None)?;
Ok(Some(child.wait(30_000)?.stdout))
} else {
Ok(Some(process::run_command(&cmd, None, None)?.stdout))
}
}
// `editor.write` is not granted: an error, not a trap.
"insert" => {
let editor = ctx.editor().ok_or_else(|| failed("no editor"))?;
@@ -158,9 +229,46 @@ impl syntax_provider::Guest for Misbehave {
}
}
fn action_answer(kind: &str) -> Result<String, types::Error> {
use openidea::plugin::{ui, views};
let items = vec![
ui::PickItem { key: "a".into(), label: "Alpha".into(), description: None },
ui::PickItem { key: "b".into(), label: "Beta".into(), description: None },
];
let answer = match kind {
"pick" => ui::pick("Choose translation file", &items)?,
"pick-many" => ui::pick_many("Choose services", &items)?.map(|keys| keys.join(",")),
"input" => ui::input("Service name", Some("Service"))?,
"confirm" => ui::confirm("Rewrite", "Apply this rewrite?")?.then(|| "confirmed".into()),
"input-multiline" => ui::input_multiline("Not allowed", None)?,
"reveal" => {
views::reveal("missing")?;
None
}
"open-url" => {
ui::open_url("https://example.com")?;
None
}
"clipboard" => {
ui::copy_to_clipboard("not allowed")?;
None
}
"pick-directory" => ui::pick_directory("Not allowed", None)?,
"open-project" => {
openidea::plugin::workspace::open_project("/not-allowed", false)?;
None
}
_ => return Err(types::Error::Invalid("unknown prompt".into())),
};
answer.ok_or(types::Error::Cancelled)
}
/// Completion: one item, labelled with the document's path, after [`behave`].
impl providers::Guest for Misbehave {
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
if doc.text == "prompt-provider" {
assert!(matches!(action_answer("input"), Err(types::Error::Invalid(_))));
}
behave(&doc.text);
// `rich`: a snippet that also adds an import.
let rich = doc.text == "rich";
@@ -189,6 +297,29 @@ impl providers::Guest for Misbehave {
/// `lazy`: one action whose edits come from `resolve-code-action`.
fn code_actions(doc: Document, _selection: types::Range) -> Vec<CodeAction> {
if doc.text == "prompts" {
return [
"pick",
"pick-many",
"input",
"confirm",
"input-multiline",
"reveal",
"open-url",
"clipboard",
"pick-directory",
"open-project",
]
.into_iter()
.map(|kind| CodeAction {
title: format!("Ask {kind}"),
kind: Some("quickfix".into()),
preferred: false,
edits: Vec::new(),
data: Some(kind.into()),
})
.collect();
}
if doc.text != "lazy" {
return Vec::new();
}
@@ -202,6 +333,19 @@ impl providers::Guest for Misbehave {
}
fn resolve_code_action(doc: Document, action: CodeAction) -> Result<CodeAction, types::Error> {
if doc.text == "prompts" {
let kind = action.data.as_deref().unwrap_or_default();
let answer = action_answer(kind)?;
// Longer than the 50 ms provider budget, within the command budget.
behave("sleep:80");
let start = types::Position { line: 0, character: 0 };
let edit = types::TextEdit { range: types::Range { start, end: start }, text: answer };
return Ok(CodeAction {
edits: vec![FileEdit { path: doc.path, edits: vec![edit] }],
data: None,
..action
});
}
if action.data.as_deref() != Some("fix") {
return Err(types::Error::Invalid("not one of mine".into()));
}
@@ -214,8 +358,20 @@ impl providers::Guest for Misbehave {
Vec::new()
}
fn line_markers(_doc: Document) -> Vec<LineMarker> {
Vec::new()
fn line_markers(doc: Document) -> Vec<LineMarker> {
if !doc.text.starts_with("marker-icons") {
return Vec::new();
}
["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"]
.into_iter()
.enumerate()
.map(|(line, icon)| LineMarker {
line: line as u32,
icon: Some(icon.into()),
tooltip: format!("Marker: {icon}"),
target: None,
})
.collect()
}
}
@@ -227,6 +383,17 @@ impl exports::openidea::plugin::file_actions::Guest for Misbehave {
_selection: types::Range,
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
if doc.text == "prompt-files" {
return vec![FileAction {
title: "Create a service".into(),
kind: Some("quickfix".into()),
preferred: false,
operations: Vec::new(),
edits: Vec::new(),
open: None,
data: Some("input".into()),
}];
}
if doc.text != "files" {
return Vec::new();
}
@@ -265,10 +432,25 @@ impl exports::openidea::plugin::file_actions::Guest for Misbehave {
}
fn resolve(
_doc: exports::openidea::plugin::file_actions::Document,
doc: exports::openidea::plugin::file_actions::Document,
action: exports::openidea::plugin::file_actions::FileAction,
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
if doc.text == "prompt-files" {
let name = action_answer("input")?;
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir);
let path = format!("{dir}/{name}.txt");
return Ok(exports::openidea::plugin::file_actions::FileAction {
operations: vec![FileOperation::Create(CreateFile {
path: path.clone(),
text: name,
overwrite: false,
})],
open: Some(path),
data: None,
..action
});
}
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"),
Binary file not shown.
@@ -1 +1 @@
b6c96915f0b0668134bc1b197ad20545f85bd2cb0941a0e83a9e06b688c96434
82f53a3f36f965c76575a08609439e0bc9cea37f1aef7cc1671e9ec72a17f120
Binary file not shown.
@@ -1 +1 @@
9c0017584418a02395ddf532454ee7440fb044c4b4a77cdf0b554dbd0d84b52c
a8d40f2224246e81e015d576a5fa70e83779af9fade614d61212b33fbb0961e9
Binary file not shown.
@@ -1 +1 @@
b90537a7745d8e3f8bb9fa0b8d5aa5e06465cf951ab25f5c4881efbeec46dd44
e60bcf1e179f6485e14de3755907c93344ef3cdf8158f01bea84343858ba7434
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-symbols"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-symbols"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
5cadc40061284bd5573f8323b02fb7ea7be18eb4323978b3f0b39337a9896774
+23
View File
@@ -0,0 +1,23 @@
id = "test.symbols"
name = "Indexed symbols fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
[capabilities]
required = ["project.read"]
[[contributes.providers]]
kinds = ["definition"]
languages = ["php", "java"]
[[contributes.commands]]
id = "symbols"
title = "Query indexed symbols"
[[contributes.commands]]
id = "remember"
title = "Remember project"
[[contributes.commands]]
id = "remembered"
title = "Query remembered project"
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff. Show more