Compare commits

..
Author SHA1 Message Date
l0ng-ai 2dc078b93b feat(plugins): commit workflow API for plugins
Plugin API 1.3 adds what commit-message generators and before-commit checks need:

- `menu = "commit.message"`: a plugin command, with the plugin's icon, in the toolbar above the commit message of the Commit tool window and the Commit dialog.
- `commit.pending-commit()` (`vcs.read`): what Commit would commit right now, per file its repository, path, status and a `git diff` of only the included files and changed blocks against HEAD (1 MiB in all), plus the message, Amend and a revision. The diff is made by the same selection the commit uses, in a copy of the index, so the index and the working tree stay as they are; unsaved editors of the included files are saved first, as Commit does. Only in trusted projects.
- `commit.set-commit-message(revision, message)` (`vcs.write`): replaces the message only while the draft is still at that revision, so text the user typed meanwhile is never overwritten (`stale`).
- `[[contributes.commitChecks]]` and the optional `commit-checker` export (worlds `plugin-commit-checks` / `commit-checks`): checks listed in Commit Options (on by default, `vcs.commit.disabledPluginChecks` turns them off) and run after the IDE's own; their problems, optionally with a location, stop the commit until Commit Anyway.

The window answers the host through `CommitRequested` / `plugin_answer_commit`, also for remote windows. The Rust SDK gains `host::pending_commit`, `host::set_commit_message`, `openidea_plugin::commit` and their mock; a `commit` test fixture exercises them. All components are rebuilt for the WIT change.

Refs #1650
2026-11-07 16:12:10 +08:00
arookieofc a36c7ed3d5 fix(lsp): drop language servers whose workspace root was deleted (#1935)
* fix(lsp): drop language servers whose workspace root was deleted (#1726)

A per-root server (e.g. a vtsls rooted at a nested tsconfig dir) is
spawned with its root as the process working directory. On Windows,
once that directory is deleted every spawn fails with os error 267 and
the crash backoff keeps respawning it, leaving a permanently 'Crashed'
entry in the Language Servers panel and a process tree that can never
work again.

- ensure_started: skip starting a server whose root no longer exists.
- on_instance_closed: after a crash, if the root is gone, drop the
  server (LanguageServerRemoved) instead of retrying forever.
- restart: return a clear error when the panel restart is pressed for
  a server whose root vanished.
- open_for_file_op: skip sampled candidates already deleted on disk.

Fixes #1726

* fix(lsp): drop dead-root servers claimed by ensure_started

A crash with the root still alive enters the restart backoff; if the root
is deleted during that sleep (or an open-file request arrives later),
ensure_started used to return early and leave a permanently "Crashed"
entry: no instance, no watcher, nothing re-arming it. Claim the Idle
server first (begin_start never claims a live one), then drop it when its
root is gone, mirroring on_instance_closed.

Adds root_deleted_during_crash_backoff_drops_the_server, which fails on
the previous code (server never removed) and passes with this fix.
2026-11-07 15:47:48 +08:00
Pedro Pessoa 50567473f7 feat(plugins): let plugins add to the hover of any language (#1931)
Plugin API 1.3 gains an optional hover-provider export (worlds plugin-hover
and hover): hover(doc, at) returns Markdown and the range it describes. A
plugin declares the hover kind under [[contributes.providers]] for any
language; the host asks it with the language server, under the edit-path
budget, and shows the server's hover first and each plugin's below a rule.
Declaring the kind needs engines.openidea ^1.3. The Rust SDK gains
openidea_plugin::hover and register_plugin!(T, hover); every shipped
component is rebuilt for the WIT change.
2026-11-07 15:29:16 +08:00
Pedro Pessoa de4c325a28 feat(run): run and debug Pest tests from the gutter (#1925)
Pest test() and it() calls and describe() blocks get gutter Run and Debug,
nested by describe and filtered by the names Pest prints (`outer` → it does
x, plus a data set suffix). Projects with vendor/bin/pest get a Pest test
configuration of the PHPUnit kind, pest commands are recognised, Pest's
pest_qn:// hints are read and test tree reruns filter Pest tests by name.
2026-11-07 14:30:17 +08:00
l0ng-ai a4a3c2105f fix(editor): remove remaining gutter icon hover backgrounds (#1923) 2026-11-07 14:24:55 +08:00
arookieofc 6efff739dd fix(vcs): reopen the repository when the pack directory changes (#1920)
Repo::gix() reopened the cached gitoxide handle only when config or packed-refs changed. An external git fetch/pull/repack writes new packs under objects/pack and moves refs, but never touches those files — so the ODB kept serving the snapshot taken at open, and refs whose tip lived only in the new pack failed to peel: branches vanished and status warned "Could not peel reference". Watch the pack directory's mtime too and reopen when it changes, so externally added objects become visible (issue #1919).

Fixes #1919
2026-11-07 13:32:05 +08:00
Jaw 8ca6e4bc01 fix(debugger): copy structured variable values with their contents (#1904) (#1917) 2026-11-07 13:15:27 +08:00
arookieofc 8d909598d5 fix(vfs): warn when the inotify watch limit leaves folders unwatched (#1918)
* fix(vfs): warn when the inotify watch limit leaves folders unwatched

When watch() hits the OS watch limit mid-tree (inotify ENOSPC/EMFILE), the affected directories were silently never watched: the project stayed stale with no hint. Record the failure on DirState and surface it through the new Watcher::unwatched_reason() — the host emits the 'sync may be slow' warning once the project opens and once on the first event batch after a late failure (deferred walk / update_watches).

Fixes #1915

* fix(vfs): latch the startup watch-limit warning so batches don't repeat it
2026-11-07 12:54:07 +08:00
l0ng-ai b99fe1704b fix(vcs): keep Git Log rows painted while dragging the scrollbar (#1914)
Dragging the Git Log scrollbar through a long history stuttered and showed
blank rows: the native scrollbar scrolls asynchronously in WebKit, so the
virtual list rendered rows a frame or more after the new offset was painted.

The list now uses a scrollbar drawn in the DOM (`Scrollbar` in `src/ui`) that
sets the offset from pointer events and notifies scroll listeners at once, so
the virtualizer renders the matching rows before the next paint. Rows are
keyed by slot so a large jump updates the mounted rows instead of rebuilding
them, overscan is lowered, and rows rendered mid-scroll draw their graph
without edge hit targets, which come back once scrolling stops.

Adds a `gitLogScrollbar` perf scenario with budgets.
2026-11-07 12:14:09 +08:00
l0ng-ai 159c2d6fd2 fix(lsp): find Go usages in build-constrained files and list their callers (#1911)
gopls builds a module for the host's GOOS/GOARCH only, so usages in files such as foo_windows.go were missing from Find Usages on macOS, and renaming left them behind, breaking the build for that platform. Those files of the searched modules are now resolved the way other modules are: each mention of the name in its own view (gopls builds one for the file's platform), added after gopls' answer and included in Rename.

Incoming Calls now also lists the callers found this way, in other modules and in build-constrained files: the declaration containing each call site.
2026-11-07 11:45:06 +08:00
l0ng-aiandtiube00001 5084388a68 fix(lsp): find Go usages without reopening gopls' files, and in other modules (#1909)
Finding usages of a common Go name such as New opened every project file that mentions it and asked gopls again, which took up to seconds and found nothing more: gopls answers from the view of the file it is asked about, and opened files only get views of their own. Go searches now skip that widening.

Usages in other modules of the project that use the declaration's module through a local replace (a multi-module repository without go.work) are found the way GoLand finds them: each mention of the name there is resolved in its own module's view, and those resolving to the declaration are added after gopls' own answer. Rename edits them too.

Supersedes #1749.

Co-authored-by: tiube00001 <[email protected]>
2026-11-07 11:29:53 +08:00
l0ng-ai 68342b32bf test(run): synchronize process shutdown tests to fix flaky CI (#1908) 2026-11-07 11:25:54 +08:00
Jaw 352efc02a7 feat(angular): highlight inline templates and auto-close tags (#1897)
Angular component templates in @Component({ template }) were parsed as TypeScript strings, so template highlighting and tag closing never applied. TypeScript files in Angular projects (angular.json or @angular/core) now use an Angular TypeScript mode that mounts the Angular template parser on literal template strings; other strings and non-Angular projects are unchanged. Inline and external templates both close tags on /, with multiple cursors and undo.

Closes #1895

Co-authored-by: jaw52 <[email protected]>
2026-11-07 11:13:09 +08:00
LU XIANGRUIandwildcatDownstairs 24ffb9f16e fix(workbench): even out the tool window stripes' spacing and draw their icons at 20px (#1741)
Stripe icon buttons were 28px with a 2px gap, plus an extra gap at each group boundary, and their 20px icons were drawn at 18px, off the pixel grid. Stripe buttons are now 30px and repeat at the title bar's height (40px, 32px compact), so the title bar's Settings button reads as the right stripe's first one; the empty drop slot after a group no longer adds a gap. Stripe, title bar and Run widget icons render at 20px.

Co-authored-by: wildcatDownstairs <[email protected]>
2026-11-07 11:13:00 +08:00
Jaw 8ebf839d8e feat(project): rename Autoscroll from Source to Always Select Opened File (#1694)
Following the active editor already worked, but the option kept IDEA's pre-2023 name and sat second under Behavior, so people didn't find it (#1666). It is now Always Select Opened File, listed first, and the header's Select Opened File button hides while it's on. Find Action still matches the old name; the setting key is unchanged.

Closes #1666

Co-authored-by: jaw52 <[email protected]>
2026-11-07 11:12:50 +08:00
Kamal ElFadlandelfadl 14a31fe8a1 feat(editor): add syntax highlighting for go.mod and go.work files (#1695)
go.mod and go.work opened as plain text: their language definitions had no ui-mode, so no CodeMirror language loaded. Add a GoMod mode (a small stream parser): directives as keywords, module paths as strings, versions as numbers, // comments; shared by go.mod and go.work.

Co-authored-by: elfadl <[email protected]>
2026-11-07 11:12:41 +08:00
Ilya Landmushketer888 1af1744290 feat(editor): highlight Arduino sketches as C++ (#1874)
Open .ino sketches with C++ highlighting and the C++ file icon. Sketches get their own Arduino language with no language server: clangd can't compile a sketch on its own (implicit Arduino.h, generated prototypes) and reported "Unable to handle compilation" on line 1 of every sketch.

Co-authored-by: mushketer888 <[email protected]>
2026-11-07 11:12:32 +08:00
146 changed files with 5404 additions and 256 deletions

No files matched your search

+3
View File
@@ -113,6 +113,8 @@ pub struct CommitSettings {
pub body_limit: u32,
pub show_right_margin: bool,
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
}
impl Default for CommitSettings {
@@ -132,6 +134,7 @@ impl Default for CommitSettings {
body_limit: 72,
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
}
}
}
+17 -2
View File
@@ -195,9 +195,24 @@ pub async fn lsp_hover(
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.hover(doc, pos, cancel).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
result
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
return result;
}
let server = match result {
Ok(server) => server,
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None,
Err(e) => return Err(e),
};
Ok(crate::plugins::merge_hover(server, hovers))
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
+165 -9
View File
@@ -1,14 +1,14 @@
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
//! Capabilities were checked by the runtime before any of this runs.
use super::{PluginContextKey, PluginEvent, PluginEventSink};
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
use ide_plugin::{
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
RepositoryStatus, StatusItem,
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -23,6 +23,11 @@ const MAX_KEY: usize = 256;
/// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8;
const MAX_CONTEXT_KEYS: usize = 100;
/// Most diff text of a pending commit, all files together.
const MAX_COMMIT_DIFF: usize = 1 << 20;
/// How long a window may take to describe its pending commit (it saves editors first).
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
/// (window or `None` for every window, plugin, key).
type ContextKey = (Option<String>, String, String);
@@ -51,6 +56,10 @@ pub trait PluginWorkspace: Send + Sync {
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
/// The encoding git content of `path` is read in (the project's file encoding).
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
encoding_rs::UTF_8.into()
}
/// The user's values of `plugin`'s settings: the project's at `root`
/// (over global), or global.
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
@@ -150,10 +159,15 @@ struct TaskEntry {
cancelled: bool,
}
/// A question a plugin's call waits on.
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
struct PromptEntry {
plugin: String,
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
answer: Waiter,
}
enum Waiter {
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
}
/// State shared by the host and every plugin's [`PluginApi`].
@@ -211,7 +225,7 @@ impl Shared {
})
}
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
self.workspace.read().clone()
}
@@ -358,8 +372,22 @@ impl Shared {
/// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let Some(prompt) = self.prompts.lock().remove(&id) else { return false };
prompt.answer.send(answer).is_ok()
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
/// A window answered a [`CommitRequest`].
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
fn end_task(&self, id: u64) {
@@ -540,6 +568,91 @@ fn kill_group(child: &mut std::process::Child) {
ide_core::process::kill_tree(child);
}
impl PluginApi {
/// Sends `request` to `client`'s window and waits for its answer, until
/// `cancel` fires, the plugin stops or `timeout` passes.
fn ask_commit(
&self,
client: &str,
request: CommitRequest,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<CommitAnswer, HostError> {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
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) });
}
let request_id = id.to_string();
self.shared.sink.emit(
Some(client),
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
);
let deadline = Instant::now() + timeout;
loop {
match answers.recv_timeout(Duration::from_millis(100)) {
Ok(answer) => return Ok(answer),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if !cancel.is_cancelled() && Instant::now() < deadline => {}
Err(e) => {
self.shared.prompts.lock().remove(&id);
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
return Err(match e {
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
HostError::Failed("the window did not answer".into())
}
_ => HostError::Cancelled,
});
}
}
}
}
}
/// The changes `selection` commits, diffed repository by repository.
pub(super) fn commit_draft(
workspace: &dyn PluginWorkspace,
selection: CommitSelection,
cancel: &CancellationToken,
) -> Result<CommitDraft, HostError> {
let mut changes = Vec::new();
let mut budget = MAX_COMMIT_DIFF;
for repo in &selection.repositories {
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
};
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
_ => HostError::Failed(e.message),
},
)?;
let root = to_ipc(r.root());
for c in pending {
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
changes.push(CommitChange {
repository: root.clone(),
path: to_ipc(&c.path),
old_path: c.old_path.as_deref().map(to_ipc),
status: c.status.to_owned(),
diff: c.diff,
truncated: c.truncated,
});
}
}
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
@@ -577,7 +690,10 @@ impl HostApi for PluginApi {
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer });
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
}
let prompt_id = id.to_string();
self.shared.sink.emit(
@@ -597,6 +713,37 @@ impl HostApi for PluginApi {
}
}
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let client = from_window(client, "read the pending commit")?;
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
let root =
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
if !workspace.trusted(&root) {
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
}
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
match answer {
CommitAnswer::NoRepository => Ok(None),
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
}
}
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let client = from_window(client, "set the commit message")?;
let request = CommitRequest::SetMessage { revision, message };
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
CommitAnswer::Done => Ok(()),
CommitAnswer::Stale => Err(HostError::Stale),
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
}
}
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
let client = from_window(client, "reveal a view")?;
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
@@ -1112,6 +1259,15 @@ impl PluginWorkspace for HostWorkspace {
Ok(())
}
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
HostWorkspace::projects(self)
.into_iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.and_then(|p| p.vcs_encoding(path, None).ok())
.unwrap_or_else(|| encoding_rs::UTF_8.into())
}
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
+28
View File
@@ -0,0 +1,28 @@
//! The Commit view's side of the `commit` API, as the window commands
//! `plugin_answer_commit` / `plugin_commit_checks` serve it — the desktop for
//! local projects, `openidea-server` for remote ones.
use super::{CommitSelection, PluginCommitCheckRef, PluginCommitCheckResult, PluginHost};
use ide_core::{IdeError, IdeResult, RequestId};
use std::sync::Arc;
/// Runs the plugins' before-commit checks `checks` on what `selection` commits,
/// until request `request` is cancelled.
pub async fn commit_checks(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
selection: CommitSelection,
checks: Vec<PluginCommitCheckRef>,
request: RequestId,
) -> IdeResult<Vec<PluginCommitCheckResult>> {
host.require_trust(window)?;
let invocation = super::views::view_invocation(host, window, None, None)?;
let cancel = host.begin_project_read_request(window, request);
let result = tokio::task::spawn_blocking(move || plugins.commit_checks(invocation, selection, checks, &cancel))
.await
.map_err(|e| IdeError::internal(e.to_string()))
.and_then(|r| r);
host.end_request(request);
result
}
+94
View File
@@ -0,0 +1,94 @@
use ide_lsp::types::{Hover, Markup};
use ide_plugin::PluginHover;
pub(crate) fn merge(server: Option<Hover>, plugin: Vec<PluginHover>) -> Option<Hover> {
if plugin.is_empty() {
return server;
}
let mut parts = Vec::with_capacity(plugin.len() + 1);
let mut range = None;
if let Some(h) = server {
range = h.range;
if !h.contents.value.trim().is_empty() {
parts.push(if h.contents.kind == "markdown" { h.contents.value } else { escape(&h.contents.value) });
}
}
for h in plugin {
range = range.or(h.range);
parts.push(h.markdown);
}
Some(Hover { contents: Markup { kind: "markdown".into(), value: parts.join("\n\n---\n\n") }, range })
}
fn escape(plain: &str) -> String {
let mut out = String::with_capacity(plain.len() + plain.len() / 4);
for c in plain.chars() {
match c {
'\n' => out.push_str(" \n"),
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '#' | '+' | '-' | '.' | '!'
| '|' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
fn range(line: u32, from: u32, to: u32) -> Range {
Range::new(Pos::new(line, from), Pos::new(line, to))
}
fn plugin(markdown: &str, at: Option<Range>) -> PluginHover {
PluginHover { markdown: markdown.into(), range: at }
}
fn server(kind: &str, value: &str, at: Option<Range>) -> Hover {
Hover { contents: Markup { kind: kind.into(), value: value.into() }, range: at }
}
#[test]
fn plugin_content_follows_the_server_hover() {
let merged = merge(
Some(server("markdown", "```php\nfunction route()\n```", Some(range(3, 4, 9)))),
vec![plugin("**orders.index** · `GET /orders`", Some(range(3, 10, 22)))],
)
.unwrap();
assert_eq!(merged.contents.kind, "markdown");
assert_eq!(merged.contents.value, "```php\nfunction route()\n```\n\n---\n\n**orders.index** · `GET /orders`");
assert_eq!(merged.range, Some(range(3, 4, 9)));
}
#[test]
fn without_a_server_hover_the_plugins_answer_alone() {
let merged = merge(None, vec![plugin("one", None), plugin("two", Some(range(1, 0, 3)))]).unwrap();
assert_eq!(merged.contents.value, "one\n\n---\n\ntwo");
assert_eq!(merged.range, Some(range(1, 0, 3)));
assert!(merge(None, Vec::new()).is_none());
}
#[test]
fn plain_text_from_the_server_keeps_its_characters() {
let merged = merge(Some(server("plaintext", "a_b *c*\nd", None)), vec![plugin("x", None)]).unwrap();
assert_eq!(merged.contents.value, "a\\_b \\*c\\* \nd\n\n---\n\nx");
}
#[test]
fn an_empty_server_hover_is_left_out() {
let merged = merge(Some(server("markdown", " ", None)), vec![plugin("x", None)]).unwrap();
assert_eq!(merged.contents.value, "x");
}
#[test]
fn no_plugin_content_keeps_the_server_hover() {
let kept = merge(Some(server("plaintext", "a_b", Some(range(0, 0, 1)))), Vec::new()).unwrap();
assert_eq!((kept.contents.kind.as_str(), kept.contents.value.as_str()), ("plaintext", "a_b"));
assert_eq!(kept.range, Some(range(0, 0, 1)));
}
}
+64
View File
@@ -16,6 +16,8 @@
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
mod api;
pub mod commit;
mod hover;
mod icon_extensions;
mod icon_patterns;
mod icon_settings;
@@ -38,6 +40,7 @@ pub mod views;
pub mod web;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub(crate) use hover::merge as merge_hover;
pub use icons::{
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
};
@@ -1364,6 +1367,67 @@ impl PluginHost {
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
}
/// A window answered a plugin's `CommitRequested`; false when the call no
/// longer waits.
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
}
/// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on
/// what `selection` commits. A check that fails or isn't available is
/// reported with `error`, so the commit still stops for it.
pub fn commit_checks(
&self,
invocation: Invocation,
selection: CommitSelection,
checks: Vec<PluginCommitCheckRef>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<PluginCommitCheckResult>> {
let declared = self.ui.items(ui_points::COMMIT_CHECKS, None);
let wanted: Vec<_> = checks
.into_iter()
.filter_map(|c| {
let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?;
Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned()))
})
.collect();
if wanted.is_empty() {
return Ok(Vec::new());
}
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e {
HostError::Cancelled => IdeError::cancelled(),
HostError::Invalid(m) => IdeError::invalid(m),
e => IdeError::unavailable(e.to_string()),
})?;
let mut out = Vec::new();
for (c, title) in wanted {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let granted = self
.plugins
.read()
.get(&c.plugin_id)
.is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead));
let result = if granted {
let (check, draft) = (c.check.clone(), draft.clone());
self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| {
rt.commit_check(&check, draft.clone(), inv)
})
.map_err(|e| e.message)
} else {
Err(HostError::Denied(Capability::VcsRead.to_string()).to_string())
};
let (problems, error) = match result {
Ok(problems) => (problems, None),
Err(e) => (Vec::new(), Some(e)),
};
out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error });
}
Ok(out)
}
/// Kinds of workbench events some running or activatable plugin wants;
/// the UI reports only these.
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
+1
View File
@@ -68,5 +68,6 @@ pub fn register_builtin_points(
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
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)))?;
Ok(())
}
+13 -1
View File
@@ -38,6 +38,7 @@ pub enum ProviderKind {
Definition,
LineMarkers,
SemanticTokens,
Hover,
}
#[derive(Clone, Debug, Deserialize)]
@@ -116,6 +117,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::Hover) {
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
@@ -164,7 +168,7 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
use super::{LoadedPlugin, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
ProviderDocument, Runtime, SemanticToken,
};
use std::sync::Weak;
@@ -345,6 +349,14 @@ impl PluginHost {
.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))
.into_iter()
.filter_map(|(_, hover)| hover)
.collect()
}
/// Ghost text from the first plugin that has some. Not bound by the
/// edit-path soft deadline (it may need the network) but by `cancel`:
/// the editor cancels a request as soon as the user types on.
+22 -1
View File
@@ -15,7 +15,7 @@
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
//! `plugin_service_log`)
//! `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
//! carry the desktop commands' names and arguments, so a remote window's UI
//! routes them here unchanged. Plugin results come back as `plugin`
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
@@ -122,6 +122,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
)
}
@@ -422,6 +423,26 @@ impl RemotePlugins {
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_answer_commit", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_commit_checks", move |c| {
let h = h.clone();
async move {
super::commit::commit_checks(
&c.host,
alive(&h)?,
&c.window,
c.arg("selection")?,
c.arg("checks")?,
c.arg("request")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
async move {
+70
View File
@@ -232,6 +232,55 @@ pub enum PluginEvent {
/// Bring the tool window of the plugin's view `view` to the front.
#[serde(rename_all = "camelCase")]
RevealView { plugin_id: String, view: String },
/// A command needs the window's Commit view: answer with
/// `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
#[serde(rename_all = "camelCase")]
CommitRequested { plugin_id: String, request_id: String, request: CommitRequest },
}
/// What a plugin asks of a window's Commit view.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CommitRequest {
/// What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
/// editors of the included files are saved first.
Selection,
/// Replace the message if the draft is still at `revision`.
SetMessage { revision: u64, message: String },
}
/// A window's answer to a [`CommitRequest`].
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CommitAnswer {
/// The window's project has no repository.
NoRepository,
Selection {
selection: CommitSelection,
},
/// The message was replaced.
Done,
/// The draft is no longer at the revision.
Stale,
}
/// What a window's Commit view would commit: per repository, the options
/// `vcs_commit` would get.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitSelection {
/// Changes whenever the message, the included changes or Amend do.
pub revision: u64,
pub message: String,
pub amend: bool,
pub repositories: Vec<CommitSelectionRepo>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitSelectionRepo {
pub root: ide_core::IpcPath,
pub options: ide_vcs::CommitOptions,
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
@@ -300,3 +349,24 @@ pub struct LineMarkerInfo {
/// Opened on click.
pub target: Option<ide_core::Location>,
}
/// A plugin's before-commit check to run (`[[contributes.commitChecks]]`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommitCheckRef {
pub plugin_id: String,
pub check: String,
}
/// What a plugin's before-commit check found.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommitCheckResult {
pub plugin_id: String,
pub check: String,
/// The check's declared title.
pub title: String,
pub problems: Vec<ide_plugin::CommitProblem>,
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
pub error: Option<String>,
}
+36 -2
View File
@@ -5,7 +5,7 @@
//!
//! ```toml
//! [[contributes.menus]]
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
//! command = "check"
//! when = "editorLang == java"
//! # view = "<view id>" # required for (only allowed on) viewItem.context
@@ -42,6 +42,10 @@
//! icon = "icons/drawing.svg"
//! iconDark = "icons/drawing_dark.svg"
//!
//! [[contributes.commitChecks]]
//! id = "conventional"
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
//!
//! [[contributes.fileTemplates]]
//! id = "drawing"
//! name = "Excalidraw Drawing"
@@ -66,9 +70,13 @@ pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
const VIEW_ITEM_MENU: &str = "viewItem.context";
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
/// The toolbar above the commit message (Commit tool window and dialog).
const COMMIT_MESSAGE_MENU: &str = "commit.message";
const MENU_IDS: [&str; 6] =
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
@@ -122,6 +130,9 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
}
declared_command(pkg, MENUS, &d.command)?;
if d.menu == COMMIT_MESSAGE_MENU {
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
}
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
@@ -554,6 +565,29 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
Ok(out)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CommitCheckDecl {
id: String,
title: String,
}
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
/// `commit-checker` export, listed in Commit Options.
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
validate_item_id(&d.id, "commit check id")?;
title(COMMIT_CHECKS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
}
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
#[cfg(test)]
mod tests {
use super::*;
+38 -2
View File
@@ -56,7 +56,7 @@ pub struct Project {
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
pub(crate) watcher: Watcher,
pub(crate) watcher: Arc<Watcher>,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
/// Watchers of folders outside the project that language servers registered file
@@ -305,6 +305,12 @@ impl Host {
for repo in repos.read().all() {
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
}
// Shared handle: `unwatched_reason` is a live query on the watcher,
// filled after `start` returns so the on-change callback can reach it.
let watcher_weak = Arc::new(std::sync::OnceLock::<std::sync::Weak<Watcher>>::new());
// One-shot latch for the post-start watch-limit warning (#1915): the
// synchronous startup walk and a later batch can both observe it.
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
let watcher = {
let kotlin_bridge = kotlin_bridge.clone();
let phpstan = phpstan.clone();
@@ -322,6 +328,8 @@ impl Host {
let lsp = lsp.clone();
let trusted = trusted.clone();
let build_cache = build_cache.clone();
let notify_watch_exhausted = notify_watch_exhausted.clone();
let watcher_weak = watcher_weak.clone();
Watcher::start(
&root.clone(),
excludes.clone(),
@@ -408,9 +416,26 @@ impl Host {
if git_dirs || vcs_moved {
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}
// A watch limit hit after startup (per-directory mode, Linux):
// folders past the limit never sync — surface it once, like
// the polling fallback below (#1915). The check itself is
// deferred to here, on a batch that already had events.
if !notify_watch_exhausted.load(Ordering::Relaxed)
&& let Some(msg) =
watcher_weak.get().and_then(std::sync::Weak::upgrade).and_then(|w| w.unwatched_reason())
{
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "External file changes sync may be slow".into(),
body: Some(msg),
});
notify_watch_exhausted.store(true, Ordering::Relaxed);
}
}),
)?
};
let watcher = Arc::new(watcher);
let _ = watcher_weak.set(Arc::downgrade(&watcher));
if let Some(reason) = watcher.polling() {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
@@ -418,6 +443,17 @@ impl Host {
body: Some(reason.into()),
});
}
// A watch can also be refused part-way through the synchronous startup
// walk (large tree, SYNC_WATCH_BUDGET) — after start() returns, before
// any batch. Emit the same warning so the limit isn't silent there either.
if let Some(reason) = watcher.unwatched_reason() {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "External file changes sync may be slow".into(),
body: Some(reason),
});
notify_watch_exhausted.store(true, Ordering::Relaxed);
}
// Scratch files live outside the project; watch them too so the
// Project view and open editors follow changes made there.
@@ -644,7 +680,7 @@ impl Host {
notebooks,
kotlin_bridge: None,
phpstan: None,
watcher,
watcher: Arc::new(watcher),
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
+253
View File
@@ -0,0 +1,253 @@
//! The `commit` interface and the `commit-checker` export, through the
//! `test.commit` fixture and a scripted window answering `CommitRequested`.
use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_plugin::{HostError, Invocation, Limits};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/commit")
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "user.name=T", "-c", "[email protected]"])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
struct Workspace {
root: PathBuf,
trusted: AtomicBool,
}
impl PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![self.root.clone()]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
Err(HostError::NotFound("not open".into()))
}
fn trusted(&self, _: &Path) -> bool {
self.trusted.load(Ordering::Relaxed)
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
Some(self.root.clone())
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
Ok(())
}
}
/// The window's Commit view: its draft, and whether the user types between
/// the plugin reading it and writing the message.
#[derive(Default)]
struct Window {
revision: u64,
message: String,
selection: Option<CommitSelection>,
type_after_reading: bool,
requests: Vec<CommitRequest>,
}
struct Fixture {
host: Arc<PluginHost>,
window: Arc<Mutex<Window>>,
workspace: Arc<Workspace>,
project: PathBuf,
_tmp: tempfile::TempDir,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
git(&project, &["init", "-q"]);
std::fs::write(project.join("a.txt"), "one\ntwo\nthree\n").unwrap();
git(&project, &["add", "."]);
git(&project, &["commit", "-qm", "base"]);
std::fs::write(project.join("a.txt"), "one\nDO NOT COMMIT\nthree\n").unwrap();
let window = Arc::new(Mutex::new(Window { revision: 7, ..Window::default() }));
let host_slot: Arc<Mutex<Option<std::sync::Weak<PluginHost>>>> = Arc::default();
let (w, slot) = (window.clone(), host_slot.clone());
// Answers on another thread, as the UI does through `plugin_answer_commit`.
let sink = Arc::new(move |_: Option<&str>, e: PluginEvent| {
let PluginEvent::CommitRequested { request_id, request, .. } = e else { return };
let (w, slot) = (w.clone(), slot.clone());
std::thread::spawn(move || {
let answer = {
let mut w = w.lock();
w.requests.push(request.clone());
match request {
CommitRequest::Selection => {
let selection = w.selection.clone();
if w.type_after_reading {
w.revision += 1;
}
match selection {
Some(s) => CommitAnswer::Selection { selection: s },
None => CommitAnswer::NoRepository,
}
}
CommitRequest::SetMessage { revision, message } if revision == w.revision => {
w.message = message;
w.revision += 1;
CommitAnswer::Done
}
CommitRequest::SetMessage { .. } => CommitAnswer::Stale,
}
};
let host = slot.lock().as_ref().and_then(std::sync::Weak::upgrade);
assert!(host.unwrap().answer_commit(&request_id, answer));
});
});
let host = Arc::new(PluginHost::new(tmp.path().join("plugins"), sink, Limits::default()).unwrap());
*host_slot.lock() = Some(Arc::downgrade(&host));
let workspace = Arc::new(Workspace { root: project.clone(), trusted: AtomicBool::new(true) });
host.set_workspace(Some(workspace.clone()));
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
Self { host, window, workspace, project, _tmp: tmp }
}
fn inv(&self) -> Invocation {
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), ..Invocation::default() }
}
/// `a.txt` included whole, with `message`.
fn selection(&self, message: &str) -> CommitSelection {
let revision = self.window.lock().revision;
CommitSelection {
revision,
message: message.into(),
amend: false,
repositories: vec![CommitSelectionRepo {
root: self.project.clone().into(),
options: ide_vcs::CommitOptions {
stage: vec![self.project.join("a.txt").into()],
run_hooks: true,
..Default::default()
},
}],
}
}
fn markers(&self, message: &str) -> Vec<PluginCommitCheckResult> {
let check = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "markers".into() };
let cancel = ide_core::CancellationToken::new();
self.host.commit_checks(self.inv(), self.selection(message), vec![check], &cancel).unwrap()
}
}
#[test]
fn a_command_reads_the_pending_commit_and_writes_its_message() {
let f = Fixture::new();
let selection = f.selection("");
f.window.lock().selection = Some(selection);
let out = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap();
assert_eq!(out.as_deref(), Some("chore: modified a.txt"));
let w = f.window.lock();
assert_eq!(w.message, "chore: modified a.txt");
assert_eq!(w.revision, 8);
// Reading the draft left the index alone: nothing is staged.
let staged = Command::new("git").current_dir(&f.project).args(["diff", "--cached", "--name-only"]).output();
assert!(staged.unwrap().stdout.is_empty());
}
#[test]
fn typing_meanwhile_makes_the_write_stale() {
let f = Fixture::new();
let selection = f.selection("");
{
let mut w = f.window.lock();
w.selection = Some(selection);
w.type_after_reading = true;
}
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("stale") || err.message.contains("changed"), "{}", err.message);
assert_eq!(f.window.lock().message, "");
}
#[test]
fn no_repository_and_restricted_mode() {
let f = Fixture::new();
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("no repository"), "{}", err.message);
f.workspace.trusted.store(false, Ordering::Relaxed);
f.window.lock().requests.clear();
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("Restricted Mode"), "{}", err.message);
assert!(f.window.lock().requests.is_empty(), "the window is not asked");
// Outside a window's command there is no Commit view to ask.
let headless = Invocation { client: None, ..f.inv() };
let err = f.host.run_command("test.commit", "describe", None, headless).unwrap_err();
assert!(err.message.contains("from a command"), "{}", err.message);
}
#[test]
fn checks_report_the_message_and_markers_in_the_included_changes() {
let f = Fixture::new();
let results = f.markers("wip");
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!((r.title.as_str(), r.error.as_deref()), ("Commit Markers", None));
let found: Vec<_> =
r.problems.iter().map(|p| (p.message.as_str(), p.path.clone(), p.range.map(|r| r.start.line))).collect();
assert_eq!(
found,
[
("The message does not start with `type: `.", None, None),
("Remove the DO NOT COMMIT marker.", Some(to_ipc(&f.project.join("a.txt"))), Some(1)),
]
);
assert_eq!(f.markers("fix: drop marker")[0].problems.len(), 1);
// Unknown checks are not run, nor those of a plugin that lost `vcs.read`.
let other = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "other".into() };
let cancel = ide_core::CancellationToken::new();
assert!(f.host.commit_checks(f.inv(), f.selection("x"), vec![other], &cancel).unwrap().is_empty());
f.host.set_capability("test.commit", "vcs.read", false).unwrap();
assert!(f.markers("wip").iter().all(|r| r.problems.is_empty()));
}
#[test]
fn the_commit_points_need_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
for (what, manifest) in [
(
"commit.message",
manifest.replace("[[contributes.commitChecks]]\nid = \"markers\"\ntitle = \"Commit Markers\"\n", ""),
),
(
"commitChecks",
manifest.replace("[[contributes.menus]]\nmenu = \"commit.message\"\ncommand = \"describe\"\n", ""),
),
] {
let pkg = tmp.path().join(what);
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.2\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3") && err.message.contains(what), "{what}: {}", err.message);
}
}
+55
View File
@@ -0,0 +1,55 @@
use ide_core::Pos;
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/hover")
}
fn host(dir: &Path) -> PluginHost {
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
}
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
}
const ORDER: &str = "<?php\nclass Order\n{\n public function user() {}\n}\n$order->user;\n$order->total;\n";
#[test]
fn a_declared_name_gets_the_plugins_hover() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
let hovers = host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(5, 10));
assert_eq!(hovers.len(), 1);
assert_eq!(hovers[0].markdown, "**user** · declared on line 4");
let range = hovers[0].range.unwrap();
assert_eq!((range.start.line, range.start.col, range.end.line, range.end.col), (5, 8, 5, 12));
assert!(host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(6, 10)).is_empty());
}
#[test]
fn only_declared_languages_are_asked() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
assert!(host.provide_hover(&doc(tmp.path(), "javascript", ORDER), Pos::new(5, 10)).is_empty());
}
#[test]
fn the_kind_needs_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let pkg = tmp.path().join("old");
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
}
+11 -2
View File
@@ -9,6 +9,7 @@ use std::sync::{Arc, OnceLock};
/// Built-in definitions, embedded at compile time. Add new languages here.
const BUILTIN: &[(&str, &str)] = &[
("arduino", include_str!("../../../languages/arduino.toml")),
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
("astro", include_str!("../../../languages/astro.toml")),
("bat", include_str!("../../../languages/bat.toml")),
@@ -601,9 +602,11 @@ mod tests {
"MsGenny",
"Vue",
"Angular Template",
"Angular TypeScript",
"ERB",
"JSP",
"Astro",
"GoMod",
];
fn reg() -> LanguageRegistry {
@@ -716,7 +719,7 @@ mod tests {
}
#[test]
fn html_of_angular_projects_is_an_angular_template() {
fn angular_projects_use_framework_modes() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
@@ -727,7 +730,12 @@ mod tests {
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
assert_eq!(mode("html", "docs/index.html"), None);
assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
for file in ["apps/web/src/app/app.ts", "apps/admin/src/app.mts", "apps/admin/src/app.cts"] {
assert_eq!(mode("typescript", file).as_deref(), Some("Angular TypeScript"), "{file}");
}
assert_eq!(mode("typescript", "docs/example.ts"), None);
assert_eq!(mode("javascript", "apps/web/src/app/app.js"), None);
assert_eq!(mode("typescriptreact", "apps/web/src/app/app.tsx"), None);
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
@@ -860,6 +868,7 @@ mod tests {
("x.h", "c"),
("x.hpp", "cpp"),
("x.cc", "cpp"),
("blink.ino", "arduino"),
("P.cs", "csharp"),
("V.swift", "swift"),
("AppDelegate.m", "objective-c"),
+354
View File
@@ -0,0 +1,354 @@
//! Go files a gopls search does not reach.
//!
//! gopls answers a search from the view of the file it is asked about: the
//! modules of a `go.work`, or that file's own module, built for the host's
//! GOOS/GOARCH without extra tags. Opening more files does not widen it (each
//! gets a view of its own), so the generic scope widening is skipped for Go.
//! Usages it leaves out are found as GoLand finds them, by resolving each
//! mention of the name in its own file's view: files of other modules that use
//! the declaration's module through a local `replace` (a multi-module
//! repository without `go.work`), and files whose build constraints exclude
//! them from the default build (`_windows.go`, `//go:build`).
use ide_core::{Pos, Range};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The files of `candidates` the search at `origin` does not cover and that can refer to
/// `declaration`: those of the covered modules excluded from their default build, and those
/// of the declaration's own module or one replacing it with its directory when not covered.
pub(crate) fn files_beyond_search(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
let mut modules = ModuleRoots::default();
let (Some(origin_module), Some(declaring)) = (modules.of(origin), modules.of(declaration)) else {
return Vec::new();
};
let Some(declaring_path) = read_module_path(&declaring) else { return Vec::new() };
let mut covered: HashSet<PathBuf> = workspace_modules(&origin_module).into_iter().collect();
covered.insert(origin_module);
let mut uses: HashMap<PathBuf, bool> = HashMap::new();
candidates
.into_iter()
.filter(|file| {
let Some(module) = modules.of(file) else { return false };
// The covered modules share the search's view: only their excluded files are missed.
if covered.contains(&module) {
return !in_default_build(file);
}
*uses.entry(module.clone()).or_insert_with(|| {
module == declaring
|| std::fs::read_to_string(module.join("go.mod")).is_ok_and(|text| {
local_replacements(&text, &module)
.iter()
.any(|(path, dir)| *path == declaring_path && *dir == declaring)
})
})
})
.collect()
}
/// Whether gopls' default view builds `file`: no build constraint other than the host's
/// own GOOS/GOARCH file name suffix.
fn in_default_build(file: &Path) -> bool {
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let stem = name.strip_suffix(".go").unwrap_or(&name);
let stem = stem.strip_suffix("_test").unwrap_or(stem);
let parts: Vec<&str> = stem.split('_').collect();
// `name_GOOS`, `name_GOARCH`, `name_GOOS_GOARCH`; a file named just `linux.go` has no constraint.
let host = |part: &&str| *part == host_goos() || *part == host_goarch();
if parts.len() > 1 {
let last = parts[parts.len() - 1];
if (GOOS.contains(&last) || GOARCH.contains(&last)) && !host(&last) {
return false;
}
if parts.len() > 2 && GOARCH.contains(&last) {
let os = parts[parts.len() - 2];
if GOOS.contains(&os) && !host(&os) {
return false;
}
}
}
std::fs::read_to_string(file).is_ok_and(|text| !constrained_header(&text))
}
/// A `//go:build` or `// +build` line before the package clause.
fn constrained_header(text: &str) -> bool {
for line in text.lines() {
let line = line.trim();
if line.starts_with("package ") {
return false;
}
if line.starts_with("//go:build") || line.starts_with("// +build") {
return true;
}
}
false
}
fn host_goos() -> &'static str {
match std::env::consts::OS {
"macos" => "darwin",
os => os,
}
}
fn host_goarch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
"powerpc64" => "ppc64",
arch => arch,
}
}
const GOOS: &[&str] = &[
"aix",
"android",
"darwin",
"dragonfly",
"freebsd",
"hurd",
"illumos",
"ios",
"js",
"linux",
"nacl",
"netbsd",
"openbsd",
"plan9",
"solaris",
"wasip1",
"windows",
"zos",
];
const GOARCH: &[&str] = &[
"386",
"amd64",
"amd64p32",
"arm",
"armbe",
"arm64",
"arm64be",
"loong64",
"mips",
"mipsle",
"mips64",
"mips64le",
"mips64p32",
"mips64p32le",
"ppc",
"ppc64",
"ppc64le",
"riscv",
"riscv64",
"s390",
"s390x",
"sparc",
"sparc64",
"wasm",
];
/// Whole-word occurrences of `word` in `text`, as ranges in UTF-16 columns.
pub(crate) fn word_ranges(text: &str, word: &str) -> Vec<Range> {
let ident = |c: char| c.is_alphanumeric() || c == '_';
let mut out = Vec::new();
for (line_no, line) in text.lines().enumerate() {
let code = line.split("//").next().unwrap_or(line);
for (at, _) in code.match_indices(word) {
let before = code[..at].chars().next_back();
let after = code[at + word.len()..].chars().next();
if before.is_some_and(ident) || after.is_some_and(ident) {
continue;
}
let col = code[..at].encode_utf16().count() as u32;
let len = word.encode_utf16().count() as u32;
out.push(Range::new(Pos::new(line_no as u32, col), Pos::new(line_no as u32, col + len)));
}
}
out
}
/// Nearest enclosing `go.mod` directory, cached per directory.
#[derive(Default)]
struct ModuleRoots(HashMap<PathBuf, Option<PathBuf>>);
impl ModuleRoots {
fn of(&mut self, file: &Path) -> Option<PathBuf> {
let dir = file.parent()?.to_path_buf();
self.dir(&dir)
}
fn dir(&mut self, dir: &Path) -> Option<PathBuf> {
if let Some(found) = self.0.get(dir) {
return found.clone();
}
let found =
if dir.join("go.mod").is_file() { Some(dir.to_path_buf()) } else { dir.parent().and_then(|p| self.dir(p)) };
self.0.insert(dir.to_path_buf(), found.clone());
found
}
}
fn read_module_path(module: &Path) -> Option<String> {
let text = std::fs::read_to_string(module.join("go.mod")).ok()?;
text.lines().find_map(|line| Some(unquote(line.trim().strip_prefix("module ")?.trim())))
}
/// The modules a `go.work` above `module` uses, as gopls loads them together.
fn workspace_modules(module: &Path) -> Vec<PathBuf> {
let Some(work) = module.ancestors().map(|d| d.join("go.work")).find(|p| p.is_file()) else { return Vec::new() };
let Ok(text) = std::fs::read_to_string(&work) else { return Vec::new() };
let base = work.parent().unwrap_or(Path::new(""));
directives(&text, "use").into_iter().map(|dir| normalize(&base.join(dir))).collect()
}
/// `replace <module> [version] => <local directory>` entries, directories resolved against `base`.
fn local_replacements(go_mod: &str, base: &Path) -> Vec<(String, PathBuf)> {
directives(go_mod, "replace")
.into_iter()
.filter_map(|entry| {
let (from, to) = entry.split_once("=>")?;
let from = unquote(from.split_whitespace().next()?);
let to = unquote(to.split_whitespace().next()?);
let local = to.starts_with("./")
|| to.starts_with("../")
|| Path::new(&to).is_absolute()
|| to == "."
|| to == "..";
local.then(|| (from, normalize(&base.join(to))))
})
.collect()
}
/// The arguments of `keyword` directives, single-line and block form.
fn directives(text: &str, keyword: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_block = false;
for line in text.lines() {
let line = line.split("//").next().unwrap_or("").trim();
if in_block {
if line == ")" {
in_block = false;
} else if !line.is_empty() {
out.push(line.to_owned());
}
} else if let Some(rest) = line.strip_prefix(keyword)
&& rest.starts_with([' ', '\t', '('])
{
let rest = rest.trim();
if rest == "(" {
in_block = true;
} else if !rest.is_empty() {
out.push(rest.to_owned());
}
}
}
out
}
fn unquote(s: &str) -> String {
s.trim_matches(|c| c == '"' || c == '`').to_owned()
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, text: &str) -> PathBuf {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
path
}
fn names(root: &Path, files: Vec<PathBuf>) -> Vec<String> {
files.iter().map(|f| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect()
}
#[test]
fn modules_replacing_the_declarations_module_are_searched() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.mod", "module example.com/app\n");
let origin = write(root, "app.go", "package app\nfunc New() {}\n");
write(root, "example/a/go.mod", "module a\n\nreplace example.com/app => ../..\n");
write(root, "example/b/go.mod", "module b\n\nreplace (\n\texample.com/app v1.0.0 => \"../../\"\n)\n");
write(root, "example/c/go.mod", "module c\n\nrequire example.com/app v1.0.0\n");
write(root, "example/d/go.mod", "module d\n\nreplace example.com/app => github.com/fork/app v1.0.0\n");
let candidates = vec![
write(root, "same.go", "package app\n"),
write(root, "same_plan9.go", "package app\n"),
write(root, &format!("same_{}.go", host_goos()), "package app\n"),
write(root, "same_plan9_test.go", "package app\n"),
write(root, "tagged.go", "// Copyright\n\n//go:build integration\n\npackage app\n"),
write(root, "plan9.go", "package app\n"),
write(root, "example/a/main.go", "package main\n"),
write(root, "example/b/main.go", "package main\n"),
write(root, "example/c/main.go", "package main\n"),
write(root, "example/d/main.go", "package main\n"),
];
assert_eq!(
names(root, files_beyond_search(&origin, &origin, candidates)),
["same_plan9.go", "same_plan9_test.go", "tagged.go", "example/a/main.go", "example/b/main.go"]
);
}
#[test]
fn go_work_modules_are_covered_by_the_first_search() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.work", "go 1.22\n\nuse (\n\t./lib // shared\n\t./app\n)\n");
write(root, "lib/go.mod", "module example.com/lib\n");
write(root, "app/go.mod", "module example.com/app\n\nreplace example.com/lib => ../lib\n");
write(root, "tool/go.mod", "module example.com/tool\n\nreplace example.com/lib => ../lib\n");
let declaration = write(root, "lib/lib.go", "package lib\n");
let candidates = vec![
write(root, "app/main.go", "package main\n"),
write(root, "app/main_plan9.go", "package main\n"),
write(root, "tool/main.go", "package main\n"),
];
assert_eq!(
names(root, files_beyond_search(&declaration, &declaration, candidates)),
["app/main_plan9.go", "tool/main.go"]
);
}
#[test]
fn a_usage_site_in_another_module_searches_the_declaring_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.mod", "module example.com/app\n");
let declaration = write(root, "app.go", "package app\n");
write(root, "cmd/go.mod", "module cmd\n\nreplace example.com/app => ../\n");
let origin = write(root, "cmd/main.go", "package main\n");
let candidates = vec![write(root, "other.go", "package app\n"), write(root, "cmd/util.go", "package main\n")];
assert_eq!(names(root, files_beyond_search(&origin, &declaration, candidates)), ["other.go"]);
}
#[test]
fn word_ranges_are_whole_words_outside_line_comments() {
let text = "x := app.New() // New here\nNewer := New\n\tvar é = New()\n";
assert_eq!(
word_ranges(text, "New"),
[
Range::new(Pos::new(0, 9), Pos::new(0, 12)),
Range::new(Pos::new(1, 9), Pos::new(1, 12)),
Range::new(Pos::new(2, 9), Pos::new(2, 12)),
]
);
}
}
+69 -2
View File
@@ -13,6 +13,7 @@ mod convert;
mod external_project;
mod file_ops;
mod file_watch;
mod go_scope;
mod handler;
mod inspect;
pub mod jdtls;
@@ -53,7 +54,7 @@ use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use server::{Conn, Running, Server};
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Weak};
@@ -506,8 +507,20 @@ impl LspManager {
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
return;
}
// `begin_start` only claims Idle servers, so a claimed server is never a
// live one. Its root is the process's working directory: starting with
// one deleted from disk fails (`os error 267` on Windows). Such a
// server can never serve again, so drop it instead of leaving a
// permanently "Crashed" entry behind (e.g. the root vanishing during
// the crash-restart backoff, or an open-file request arriving later).
let Some(generation) = server.begin_start() else { return };
let Some(me) = self.me.upgrade() else { return };
if !server.root.is_dir() {
tracing::info!("{}: workspace root {} is gone, dropping", server.name, server.root.display());
server.announce_down();
self.forget(std::slice::from_ref(server));
return;
}
let server = server.clone();
self.runtime.spawn(async move { me.run_instance(server, generation).await });
}
@@ -704,6 +717,14 @@ impl LspManager {
server.announce_down();
return;
}
// The workspace root gone means this server can never serve again:
// restarting would respawn with a dead working directory, so the entry
// is dropped instead of staying "Crashed" forever.
if !server.root.is_dir() {
server.announce_down();
self.forget(std::slice::from_ref(&server));
return;
}
let tail = server.stderr_tail(5);
let cfg = server.effective_config();
let mut message = match (code, &cfg.connect) {
@@ -1688,6 +1709,19 @@ impl LspManager {
}
let mut v = out.finish(References::default())?;
convert::dedup_locations(&mut v.locations);
if project_wide && let Some(t) = targets.first().filter(|_| snap.language.as_str() == "go") {
// Usages in other modules take a view per module: those in reach come first.
if let Some(partial) = partial
&& !v.locations.is_empty()
{
partial(v.clone());
}
v.locations.extend(self.go_mentions_beyond_search(&snap, t, pos, &cancel, false).await.locations);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
convert::dedup_locations(&mut v.locations);
}
Ok(v)
}
@@ -2120,7 +2154,20 @@ impl LspManager {
};
let w = self.widened(snap, t, pos, cancel, query, files).await?;
// The second edit covers the first: its files stay analysed.
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
let mut edit = w.second.unwrap_or(w.first);
let mut more: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
for l in self.go_mentions_beyond_search(snap, t, pos, cancel, false).await.locations {
more.entry(l.path.to_path_buf())
.or_default()
.push(TextEdit { range: l.range, new_text: new_name.to_owned() });
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let files =
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
Ok(Renamed { edit, incomplete: w.incomplete })
}
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
@@ -2189,6 +2236,10 @@ impl LspManager {
},
);
for file in candidate {
// A move may have already removed the sampled entry on disk.
if !file.is_file() {
continue;
}
let language = self.languages.detect(&file, None);
if !languages.contains(&language) {
continue;
@@ -2565,6 +2616,15 @@ impl LspManager {
}
HierarchyDirection::Outgoing => (query().await?, None),
};
let mut out = out;
if direction == HierarchyDirection::Incoming {
for caller in self.go_mentions_beyond_search(&snap, t, pos, cancel, true).await.callers {
match out.iter_mut().find(|i| i.path == caller.path && i.range == caller.range) {
Some(item) => item.from_ranges.extend(caller.from_ranges),
None => out.push(caller),
}
}
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
@@ -3036,6 +3096,13 @@ impl LspManager {
.find(|s| s.id == server)
.cloned()
.ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?;
if !s.root.is_dir() {
return Err(IdeError::unavailable(format!(
"the workspace root of {} is gone: {}",
s.name,
s.root.display()
)));
}
self.stop_instance(&s, false).await;
s.reset_for_restart();
self.ensure_started(&s);
+184 -3
View File
@@ -10,9 +10,11 @@
//! closed again. Diagnostics the server publishes for them are dropped.
use crate::server::{Running, Server};
use crate::types::ReferencesIncomplete;
use crate::text::DocText;
use crate::types::{HierarchyItem, ReferencesIncomplete};
use crate::{DocSnap, LspManager, Target};
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos};
use futures::StreamExt;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos};
use lsp_types as lsp;
use serde_json::json;
use std::collections::HashSet;
@@ -51,6 +53,14 @@ pub struct CandidateFiles {
/// blocking thread and should stop early when cancelled.
pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync;
/// What a Go search adds beyond gopls' own answer.
#[derive(Default)]
pub(crate) struct GoMentions {
pub locations: Vec<Location>,
/// The declarations containing them, as incoming calls (when asked for).
pub callers: Vec<HierarchyItem>,
}
/// A reference-based answer and, when files were opened for it, the second one.
pub(crate) struct Widened<R> {
pub first: R,
@@ -110,7 +120,9 @@ impl LspManager {
Fut: Future<Output = IdeResult<R>>,
{
let busy = t.server.is_busy();
let word = search_word(snap, pos).filter(|_| t.caps().open_close);
// gopls searches the view of the file asked about; files opened for it only get views of
// their own (see `go_scope`).
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
let candidates = async {
match word {
Some(w) => self.candidates(t, w, cancel).await,
@@ -162,6 +174,175 @@ impl LspManager {
}
}
/// 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.
pub(crate) async fn go_mentions_beyond_search(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: &CancellationToken,
callers: bool,
) -> GoMentions {
if snap.language.as_str() != "go" || !t.caps().open_close {
return GoMentions::default();
}
let Some(word) = search_word(snap, pos) else { return GoMentions::default() };
let declarations: Vec<Location> = self
.goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None)
.await
.unwrap_or_default()
.into_iter()
.filter(|l| l.uri.is_none())
.collect();
let Some(declaration) = declarations.first() else { return GoMentions::default() };
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
let files = tokio::task::spawn_blocking(move || {
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
.into_iter()
.filter_map(|f| {
let text = std::fs::read_to_string(&f).ok()?;
let ranges = crate::go_scope::word_ranges(&text, &word);
(!ranges.is_empty()).then(|| (f, DocText::new(&text), ranges))
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
if files.is_empty() || cancel.is_cancelled() {
return GoMentions::default();
}
// Open, each file gives gopls a view to resolve its mentions in (its module, its build).
let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await;
let texts: Vec<(PathBuf, Arc<DocText>)> =
files.iter().map(|(path, text, _)| (path.clone(), Arc::new(text.clone()))).collect();
let probes: Vec<(PathBuf, Arc<DocText>, ide_core::Range)> = files
.into_iter()
.zip(&texts)
.flat_map(|((path, _, ranges), (_, text))| {
let text = text.clone();
ranges.into_iter().map(move |r| (path.clone(), text.clone(), r))
})
.collect();
let declarations = &declarations;
let found: Vec<Option<Location>> = futures::stream::iter(probes)
.map(|(path, text, range)| async move {
let params = json!({
"textDocument": { "uri": crate::uri::path_to_uri_string(&path) },
"position": text.ide_to_lsp(t.enc(), range.start),
});
let r = crate::loading::while_loading(
&t.server,
Some(cancel),
crate::loading::LOADING_WAIT,
crate::goto_is_empty,
|| {
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
t,
"textDocument/definition",
params.clone(),
Some(cancel),
crate::DEFAULT_TIMEOUT,
)
},
)
.await;
let targets = match r {
Ok(Some(r)) => crate::convert::goto_response(&self.conv(t.enc()), r),
_ => return None,
};
let same = targets
.iter()
.any(|l| declarations.iter().any(|d| d.path == l.path && d.range.start == l.range.start));
same.then(|| Location { path: path.into(), range, uri: None })
})
.buffer_unordered(8)
.collect()
.await;
let locations: Vec<Location> = found.into_iter().flatten().collect();
let mut out = GoMentions { callers: Vec::new(), locations };
if callers && !cancel.is_cancelled() {
for (path, text) in &texts {
let here: Vec<ide_core::Range> =
out.locations.iter().filter(|l| l.path == *path).map(|l| l.range).collect();
if !here.is_empty() {
out.callers.extend(self.go_callers_in(t, path, text, &here, cancel).await);
}
}
}
if cancel.is_cancelled() {
return GoMentions::default();
}
out
}
/// The declarations of `path` containing `calls`, innermost first, each with its call sites.
async fn go_callers_in(
&self,
t: &Target,
path: &Path,
text: &DocText,
calls: &[ide_core::Range],
cancel: &CancellationToken,
) -> Vec<HierarchyItem> {
let params = json!({ "textDocument": { "uri": crate::uri::path_to_uri_string(path) } });
let r: IdeResult<Option<lsp::DocumentSymbolResponse>> =
self.call(t, "textDocument/documentSymbol", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
let range =
|r: lsp::Range| ide_core::Range::new(text.lsp_to_ide(t.enc(), r.start), text.lsp_to_ide(t.enc(), r.end));
// (name, kind, detail, range, selection range) of every declaration.
let mut symbols = Vec::new();
match r {
Ok(Some(lsp::DocumentSymbolResponse::Nested(nested))) => {
let mut stack = nested;
while let Some(s) = stack.pop() {
symbols.push((s.name, s.kind, s.detail, range(s.range), range(s.selection_range)));
stack.extend(s.children.unwrap_or_default());
}
}
#[allow(deprecated)]
Ok(Some(lsp::DocumentSymbolResponse::Flat(flat))) => {
for s in flat {
let r = range(s.location.range);
symbols.push((s.name, s.kind, None, r, r));
}
}
_ => return Vec::new(),
}
let contains =
|outer: &ide_core::Range, inner: &ide_core::Range| outer.start <= inner.start && inner.end <= outer.end;
let mut items: Vec<HierarchyItem> = Vec::new();
for call in calls {
// The innermost function or method; a package-level declaration otherwise.
let callable = |k: lsp::SymbolKind| matches!(k, lsp::SymbolKind::FUNCTION | lsp::SymbolKind::METHOD);
let innermost = |pick: &dyn Fn(lsp::SymbolKind) -> bool| {
symbols
.iter()
.filter(|(_, kind, _, r, _)| pick(*kind) && contains(r, call))
.min_by_key(|(_, _, _, r, _)| (r.end.line - r.start.line, r.end.col.saturating_sub(r.start.col)))
};
let Some((name, kind, detail, r, selection)) = innermost(&callable).or_else(|| innermost(&|_| true)) else {
continue;
};
match items.iter_mut().find(|i| i.range == *r && i.name == *name) {
Some(item) => item.from_ranges.push(*call),
None => items.push(HierarchyItem {
name: name.clone(),
kind: crate::convert::symbol_kind(*kind),
detail: detail.clone(),
path: path.to_path_buf().into(),
uri: None,
range: *r,
selection_range: *selection,
from_ranges: vec![*call],
}),
}
}
items
}
/// Opens `files` on `t` (read from disk) unless the user has them open;
/// they are closed when the returned guard drops. Files too large or not
/// readable as text are left out and flagged on the guard.
+214
View File
@@ -1235,6 +1235,61 @@ async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
}
/// The whole workspace root deleted: the server can never start again
/// (its cwd is gone), so the crash drops the entry instead of restarting.
#[tokio::test(flavor = "multi_thread")]
async fn a_crash_below_a_deleted_workspace_root_drops_the_server() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
// A crash on a root that still exists restarts; removing it changes that.
s.peer().kill();
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
eventually(|| s.launches.load(Ordering::SeqCst) == 2).await;
h.wait_status("mock", ServerStatus::Ready).await;
std::fs::remove_dir_all(&h.root).unwrap();
s.peer().kill();
eventually(|| h.removed("mock")).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 2, "no restart with a dead working directory");
}
/// The root deleted while a crash backoff is still sleeping: the pending
/// restart must drop the server rather than leave a permanently "Crashed" row.
#[tokio::test(flavor = "multi_thread")]
async fn root_deleted_during_crash_backoff_drops_the_server() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
// Crash with the root alive: "Crashed" is published before the backoff
// sleep, so removing the root here lands inside that window.
s.peer().kill();
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
std::fs::remove_dir_all(&h.root).unwrap();
eventually(|| h.removed("mock")).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart with a dead working directory");
}
/// A manual restart with the root gone fails instead of spawning into a dead cwd.
#[tokio::test(flavor = "multi_thread")]
async fn restarting_below_a_deleted_workspace_root_fails() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let id = h.mgr.servers()[0].id;
std::fs::remove_dir_all(&h.root).unwrap();
let err = h.mgr.restart(id).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("workspace root"), "{err:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_server_reports_once() {
let h = harness(&[("ghost", json!({}))]);
@@ -2930,6 +2985,165 @@ async fn references_open_unopened_files_that_mention_the_name() {
assert_eq!(seen.lock().len(), 2);
}
/// A Go project: the module at the root, `tools` replacing it with `../` and `other` requiring a
/// published copy. The mock answers a definition in `tools` like gopls in that module's view.
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf, PathBuf) {
let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]);
let s = h.launcher.state("gopls");
let write = |rel: &str, text: &str| {
let path = h.root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
path
};
write("go.mod", "module example.com/app\n");
write("tools/go.mod", "module example.com/tools\n\nreplace example.com/app => ../\n");
write("other/go.mod", "module example.com/other\n\nrequire example.com/app v1.0.0\n");
let same = write("same.go", "package app\nvar x = New()\n");
// Line 1 uses the declaration; line 2 is a local `New` of the tools module.
let tools = write("tools/gen.go", "package tools\nvar a = app.New()\nfunc New() {}\n");
write("other/o.go", "package other\nvar b = app.New()\n");
// Built for plan9 only: outside gopls' default view of the root module.
let plan9 = write("boot_plan9.go", "package app\nfunc boot() { New() }\n");
let decl = h.root.join("a.go");
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go"), plan9.clone()];
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: candidates.clone(),
limit_hit: false,
}));
let at = |line: u32, col: u32| json!({ "start": { "line": line, "character": col }, "end": { "line": line, "character": col + 3 } });
let (decl_uri, same_uri, tools_uri, plan9_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools), uri_of(&plan9));
let span = |line: u32, from: u32, to: u32| json!({ "start": { "line": line, "character": from }, "end": { "line": line, "character": to } });
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
let line = params["position"]["line"].as_u64().unwrap_or(0);
match method {
// The view of the root module: the declaration and its usage in same.go.
"textDocument/references" => Some(json!([
{ "uri": decl_uri, "range": at(0, 5) },
{ "uri": same_uri, "range": at(1, 8) },
])),
"textDocument/rename" => Some(json!({ "changes": {
decl_uri.clone(): [{ "range": at(0, 5), "newText": "Make" }],
same_uri.clone(): [{ "range": at(1, 8), "newText": "Make" }],
} })),
"textDocument/definition" if uri == decl_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
"textDocument/definition" if uri == tools_uri && line == 1 => {
Some(json!({ "uri": decl_uri, "range": at(0, 5) }))
}
"textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })),
"textDocument/definition" if uri == plan9_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
"textDocument/documentSymbol" if uri == tools_uri => Some(json!([
{ "name": "a", "kind": 13, "range": span(1, 0, 17), "selectionRange": span(1, 4, 5) },
{ "name": "New", "kind": 12, "range": span(2, 0, 13), "selectionRange": span(2, 5, 8) },
])),
"textDocument/documentSymbol" if uri == plan9_uri => Some(json!([
{ "name": "boot", "kind": 12, "range": span(1, 0, 20), "selectionRange": span(1, 5, 9) },
])),
"textDocument/prepareCallHierarchy" => Some(
json!([{ "name": "New", "kind": 12, "uri": decl_uri, "range": span(0, 0, 13), "selectionRange": at(0, 5) }]),
),
"callHierarchy/incomingCalls" => Some(json!([{
"from": { "name": "x", "kind": 13, "uri": same_uri, "range": span(1, 0, 13), "selectionRange": span(1, 4, 5) },
"fromRanges": [at(1, 8)],
}])),
_ => None,
}
}));
(h, s, decl, same, tools, plan9)
}
#[tokio::test(flavor = "multi_thread")]
async fn go_searches_resolve_other_modules_that_replace_the_declarations_module() {
let (h, s, decl, same, tools, plan9) = go_modules_harness();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let partials = Arc::new(Mutex::new(Vec::new()));
let seen = partials.clone();
let partial = move |r: ide_lsp::types::References| seen.lock().push(r.locations.len());
let refs = h
.mgr
.references_streaming(doc, Pos::new(0, 5), true, true, true, Default::default(), Some(&partial))
.await
.unwrap();
let mut found: Vec<(PathBuf, u32)> =
refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect();
found.sort();
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1), (plan9.clone(), 1)];
want.sort();
assert_eq!(found, want);
// Those in gopls' reach arrive first.
assert_eq!(*partials.lock(), [2]);
// Only the module replacing the declaration's module was opened, never the root module's file.
let opened = uris(&s.notified("textDocument/didOpen"));
assert!(opened.contains(&uri_of(&tools)));
assert!(opened.contains(&uri_of(&plan9)));
assert!(!opened.contains(&uri_of(&same)));
assert!(!opened.iter().any(|u| u.ends_with("other/o.go")));
assert_eq!(s.requested("textDocument/references").len(), 1);
let renamed = h.mgr.rename(doc, Pos::new(0, 5), "Make".into(), Default::default()).await.unwrap();
let mut edited: Vec<(PathBuf, Vec<u32>)> = renamed
.edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, edits, .. } => {
Some((path.to_path_buf(), edits.iter().map(|e| e.range.start.line).collect()))
}
_ => None,
})
.collect();
edited.sort();
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1]), (plan9, vec![1])];
want.sort();
assert_eq!(edited, want);
}
#[tokio::test(flavor = "multi_thread")]
async fn go_incoming_calls_include_callers_beyond_the_search() {
let (h, s, _, same, tools, plan9) = go_modules_harness();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let calls =
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()).await.unwrap();
let mut callers: Vec<(PathBuf, String, Vec<u32>)> = calls
.items
.iter()
.map(|i| (i.path.to_path_buf(), i.name.clone(), i.from_ranges.iter().map(|r| r.start.line).collect()))
.collect();
callers.sort();
let mut want = vec![
(same, "x".to_owned(), vec![1]),
(tools, "a".to_owned(), vec![1]),
(plan9.clone(), "boot".to_owned(), vec![1]),
];
want.sort();
assert_eq!(callers, want);
let boot = calls.items.iter().find(|i| i.name == "boot").unwrap();
assert_eq!((boot.selection_range.start, boot.from_ranges[0].start), (Pos::new(1, 5), Pos::new(1, 14)));
}
#[tokio::test(flavor = "multi_thread")]
async fn go_searches_open_nothing_when_no_other_module_uses_the_declaration() {
let (h, s, _, same, _, plan9) = go_modules_harness();
std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap();
std::fs::remove_file(&plan9).unwrap();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert_eq!(uris(&s.notified("textDocument/didOpen")).len(), 1, "only a.go");
assert!(s.requested("textDocument/definition").len() <= 1);
assert!(!uris(&s.notified("textDocument/didOpen")).contains(&uri_of(&same)));
}
#[tokio::test(flavor = "multi_thread")]
async fn references_never_reopen_or_close_the_users_files() {
let h = harness(&[("mock", incremental_caps())]);
+93 -4
View File
@@ -4,7 +4,8 @@
//! themselves are in `wit_host`.
use crate::types::{
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument, SemanticToken,
CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
ProviderDocument, SemanticToken,
};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
@@ -15,8 +16,8 @@ use crate::types::{
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{
bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings, syntax_bindings,
views_bindings,
bindings, commit_checks_bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings,
services_bindings, syntax_bindings, views_bindings,
};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
@@ -33,6 +34,8 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
@@ -114,6 +117,22 @@ pub trait HostApi: Send + Sync {
Ok(Vec::new())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
/// The commit being prepared in `client`'s window (`None`: no repository),
/// waiting for the window until `cancel` fires.
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let _ = (client, cancel);
Ok(None)
}
/// Replaces the commit message of `client`'s window if its draft is still at
/// `revision` (else [`HostError::Stale`]).
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let _ = (client, revision, message);
Err(HostError::Invalid("no commit is being prepared".into()))
}
/// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`.
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
@@ -524,6 +543,8 @@ struct Exports {
views: Option<wit_view::Guest>,
semantic: Option<wit_sem::Guest>,
services: Option<wit_svc::Guest>,
hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
}
/// Most results one syntax call may return (more are dropped).
@@ -605,6 +626,8 @@ impl Runtime {
let view_indices = wit_view::GuestIndices::new(&pre).ok();
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices {
@@ -631,9 +654,17 @@ impl Runtime {
Some(indices) => Some(indices.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,
};
let commit_checks = match commit_check_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
Ok(Self {
store,
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services },
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover, commit_checks },
limits: setup.limits,
version: setup.version,
})
@@ -891,6 +922,29 @@ impl Runtime {
.collect())
}
pub fn provide_hover(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Option<PluginHover>, CallError> {
if self.exports.hover.is_none() {
return Err(CallError::missing_export("hover-provider"));
}
let d = wit_hover::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let hover = self.call(Invocation::default(), timeout, |e, store| {
e.hover.as_ref().expect("checked").call_hover(store, &d, pos_to_wit(at))
})?;
Ok(hover
.filter(|h| !h.markdown.trim().is_empty())
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
}
pub fn activate(&mut self) -> Result<(), CallError> {
let data = self.store.data();
let ctx = wit_types::Context {
@@ -979,6 +1033,30 @@ impl Runtime {
}
}
/// Runs the plugin's before-commit check `check` on `draft`.
pub fn commit_check(
&mut self,
check: &str,
draft: CommitDraft,
invocation: Invocation,
) -> Result<Vec<CommitProblem>, CallError> {
if self.exports.commit_checks.is_none() {
return Err(CallError::missing_export("commit-checker"));
}
let draft = crate::wit_host::commit_draft_to_wit(draft);
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let checks = e.commit_checks.as_ref().expect("checked");
let r = checks.call_check(&mut *store, check, &draft, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
match result {
Ok(problems) => Ok(problems.into_iter().take(MAX_COMMIT_PROBLEMS).map(commit_problem_from_wit).collect()),
Err(e) => Err(CallError::Guest(e.into())),
}
}
/// A user action in a view or custom editor.
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
if self.exports.views.is_none() {
@@ -1068,6 +1146,7 @@ impl Runtime {
const MAX_PROVIDER_ITEMS: usize = 10_000;
const MAX_SEMANTIC_TOKENS: usize = 1 << 16;
const MAX_TOKEN_MODIFIERS: usize = 8;
const MAX_HOVER_BYTES: usize = 64 * 1024;
fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position {
wit_types::Position { line: p.line, character: p.col }
@@ -1176,6 +1255,16 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
s.map(|s| clip(s, max))
}
const MAX_COMMIT_PROBLEMS: usize = 50;
fn commit_problem_from_wit(p: wit_cc::CommitProblem) -> CommitProblem {
let (path, range) = match p.location {
Some(l) => (Some(clip(l.path, 4096)), Some(range_from_wit(l.range))),
None => (None, None),
};
CommitProblem { message: clip(p.message, 4096), path, range }
}
const MAX_SERVICE_TABS: usize = 8;
const MAX_LOG_TOGGLES: usize = 8;
const MAX_LOG_ARGS: usize = 256;
+40
View File
@@ -210,6 +210,40 @@ pub struct RepositoryStatus {
pub changed_files: u32,
}
/// A file a pending commit changes (WIT `commit.commit-change`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitChange {
pub repository: String,
pub path: String,
pub old_path: Option<String>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
pub status: String,
/// `git diff` of the included changes; `None` for binary content.
pub diff: Option<String>,
pub truncated: bool,
}
/// What Commit would commit (WIT `commit.commit-draft`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitDraft {
pub revision: u64,
pub message: String,
pub amend: bool,
pub changes: Vec<CommitChange>,
}
/// A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitProblem {
pub message: String,
/// Absolute path of the file it is in, if any (IPC form).
pub path: Option<String>,
pub range: Option<ide_core::Range>,
}
/// Resource limits and supervision policy for plugin code.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
@@ -426,6 +460,12 @@ pub struct SemanticToken {
pub modifiers: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginHover {
pub markdown: String,
pub range: Option<ide_core::Range>,
}
// ---- declarative views (wit/views.wit) ---------------------------------------
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
+82 -2
View File
@@ -42,6 +42,7 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -69,6 +70,7 @@ pub(crate) mod providers_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -96,6 +98,7 @@ pub(crate) mod views_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -109,8 +112,8 @@ pub(crate) mod views_bindings {
}
use bindings::openidea::plugin::{
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
vcs, views, workspace,
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
types as wit, ui, vcs, views, workspace,
};
/// A project handle: its root.
@@ -571,6 +574,62 @@ impl vcs::Host for StoreData {
}
}
impl commit::Host for StoreData {
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
let client = self.invocation.client.clone();
let started = Instant::now();
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
let waited = started.elapsed();
self.deadline += waited;
self.call_started += waited;
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
}
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::VcsWrite) {
return Ok(Err(e));
}
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
if message.len() > MAX_COMMIT_MESSAGE {
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
}
let client = self.invocation.client.clone();
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
}
}
/// Longest commit message a plugin may set.
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
commit::CommitDraft {
revision: d.revision,
message: d.message,
amend: d.amend,
changes: d
.changes
.into_iter()
.map(|c| commit::CommitChange {
repository: c.repository,
path: c.path,
old_path: c.old_path,
status: c.status,
diff: c.diff,
truncated: c.truncated,
})
.collect(),
}
}
impl events::Host for StoreData {
fn subscribe(&mut self, kind: String) -> Fallible<()> {
let Some(kind) = EventKind::parse(&kind) else {
@@ -828,9 +887,30 @@ pub(crate) mod services_bindings {
});
}
/// The optional `commit-checker` export.
pub(crate) mod commit_checks_bindings {
wasmtime::component::bindgen!({
world: "commit-checks", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
},
});
}
pub(crate) mod semantic_bindings {
wasmtime::component::bindgen!({
world: "semantic-tokens", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod hover_bindings {
wasmtime::component::bindgen!({
world: "hover", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
+1
View File
@@ -29,6 +29,7 @@ fn components() -> Vec<PathBuf> {
repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"),
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"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
+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-commit"
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-commit"
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"
+9
View File
@@ -0,0 +1,9 @@
# Commit test fixture
`Describe Changes` (in the `commit.message` toolbar) reads the commit being
prepared and writes a message listing its files; the `markers` before-commit
check reports a message without a `type: ` prefix and every `DO NOT COMMIT`
added by the commit.
Used by `cargo test -p ide-host --test plugin_commit` to exercise the `commit`
interface and the optional `commit-checker` export. It is not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
+21
View File
@@ -0,0 +1,21 @@
id = "test.commit"
name = "Commit fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = ["vcs.read", "vcs.write"]
[[contributes.commands]]
id = "describe"
title = "Commit Fixture: Describe Changes"
[[contributes.menus]]
menu = "commit.message"
command = "describe"
[[contributes.commitChecks]]
id = "markers"
title = "Commit Markers"
+57
View File
@@ -0,0 +1,57 @@
use openidea_plugin::commit::{CommitChecks, CommitProblem};
use openidea_plugin::{CommitDraft, Error, Invocation, Location, Plugin, Position, Range, host, register_plugin};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
if id != "describe" {
return Err(Error::NotFound(id.into()));
}
let draft = host::pending_commit()?.ok_or("no repository")?;
let names: Vec<String> = draft
.changes
.iter()
.map(|c| format!("{} {}", c.status, c.path.rsplit('/').next().unwrap_or(&c.path)))
.collect();
let message = format!("chore: {}", names.join(", "));
host::set_commit_message(draft.revision, &message)?;
Ok(Some(message))
}
}
impl CommitChecks for Fixture {
fn check(&mut self, check: &str, draft: &CommitDraft, _: &Invocation) -> Result<Vec<CommitProblem>, Error> {
if check != "markers" {
return Err(Error::NotFound(check.into()));
}
let mut problems = Vec::new();
if !draft.message.split_once(": ").is_some_and(|(t, _)| !t.is_empty() && !t.contains(' ')) {
problems.push(CommitProblem::new("The message does not start with `type: `."));
}
for change in &draft.changes {
let Some(diff) = &change.diff else { continue };
let mut line = 0u32;
for l in diff.lines() {
if let Some(hunk) = l.strip_prefix("@@ -") {
// `@@ -a,b +c,d @@`: c is the 1-based first line of the new side.
let new = hunk.split(" +").nth(1).and_then(|s| s.split([',', ' ']).next());
line = new.and_then(|n| n.parse::<u32>().ok()).unwrap_or(1).saturating_sub(1);
} else if l.starts_with('+') && !l.starts_with("+++") {
if l.contains("DO NOT COMMIT") {
let at = Position::new(line, 0);
let location = Location { path: change.path.clone(), range: Range::new(at, at) };
problems.push(CommitProblem::new("Remove the DO NOT COMMIT marker.").at(location));
}
line += 1;
} else if !l.starts_with('-') {
line += 1;
}
}
}
Ok(problems)
}
}
register_plugin!(Fixture, commit_checks);
Binary file not shown.
@@ -1 +1 @@
670aec4d79e92537efb7048698bbb8f236cd6006524c74459891b03c9b613450
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
+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-hover"
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-hover"
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"
+7
View File
@@ -0,0 +1,7 @@
# Hover test fixture
Over a name the PHP document declares as `function <name>(`, shows
`**<name>** · declared on line <n>` for the name's range; nothing elsewhere.
Used by `cargo test -p ide-host --test plugin_hover` to exercise the optional
`hover-provider` export. It is not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
+9
View File
@@ -0,0 +1,9 @@
id = "test.hover"
name = "Hover fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
[[contributes.providers]]
kinds = ["hover"]
languages = ["php"]
+31
View File
@@ -0,0 +1,31 @@
use openidea_plugin::hover::{Document, Hover, HoverProvider};
use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin, text};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(id.into()))
}
}
impl HoverProvider for Fixture {
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
let text = doc.text.as_str();
let offset = text::offset(text, at)?;
let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
let start = text[..offset].rfind(|c: char| !word(c)).map_or(0, |i| i + 1);
let end = text[offset..].find(|c: char| !word(c)).map_or(text.len(), |n| offset + n);
let name = &text[start..end];
if name.is_empty() {
return None;
}
let declared = text.find(&format!("function {name}("))?;
let line = text::position(text, declared).line + 1;
let range = Range { start: text::position(text, start), end: text::position(text, end) };
Some(Hover::new(format!("**{name}** · declared on line {line}")).range(range))
}
}
register_plugin!(Fixture, hover);
Binary file not shown.
@@ -1 +1 @@
c913c2c94fecb43d00f73f3171cc2fda54caf7a28f170424b0addb1d40ed4cae
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
Binary file not shown.
@@ -1 +1 @@
911388ae62843d7b1e4c40d91307fbe507535c60680a9626cc07d724b75a9dfb
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
Binary file not shown.
@@ -1 +1 @@
a3818b6ec40df8f141290537a7c024aadb02d7be1eb063ca9170799ca3271898
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
Binary file not shown.
@@ -1 +1 @@
9e4c12fdf905227128d97e92639cf336124929c37384e5f5796e27ebfc5a7b37
c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3
+17 -6
View File
@@ -1048,10 +1048,15 @@ fn is_composer_event(name: &str) -> bool {
name.starts_with("pre-") || name.starts_with("post-") || name == "command"
}
/// The PHPUnit entry point: the project's `vendor/bin/phpunit`, else a global `phpunit`.
fn phpunit_command(root: &Path) -> String {
let bin = if cfg!(windows) { "vendor/bin/phpunit.bat" } else { "vendor/bin/phpunit" };
if root.join(bin).is_file() { bin.to_owned() } else { "phpunit".to_owned() }
/// The PHPUnit entry point: the project's `vendor/bin/pest` or `vendor/bin/phpunit`, else a global `phpunit`.
fn phpunit_command(root: &Path) -> (String, &'static str) {
for (runner, label) in [("pest", "Pest"), ("phpunit", "PHPUnit")] {
let bin = format!("vendor/bin/{runner}{}", if cfg!(windows) { ".bat" } else { "" });
if root.join(&bin).is_file() {
return (bin, label);
}
}
("phpunit".to_owned(), "PHPUnit")
}
/// Composer projects (`composer.json`) and PHPUnit suites (`phpunit.xml`, `phpunit.xml.dist`).
@@ -1085,8 +1090,14 @@ fn detect_php(root: &Path, out: &mut Vec<RunConfig>) {
);
out.push(if name == "test" { c.test() } else { c });
}
if phpunit_xml.is_some() || requires("phpunit/phpunit") || root.join("vendor/bin/phpunit").is_file() {
let mut c = config("phpunit", "PHPUnit", "phpunit", &phpunit_command(root), &[]).test().debug("php-debug");
if phpunit_xml.is_some()
|| requires("phpunit/phpunit")
|| requires("pestphp/pest")
|| root.join("vendor/bin/phpunit").is_file()
|| root.join("vendor/bin/pest").is_file()
{
let (command, label) = phpunit_command(root);
let mut c = config("phpunit", label, "phpunit", &command, &[]).test().debug("php-debug");
// A configuration file with a non-default name must be passed explicitly.
let named = phpunit_xml.as_deref().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned());
if let Some(file) = named.filter(|f| f == "phpunit.dist.xml") {
+21
View File
@@ -827,6 +827,27 @@ fn php_phpunit_project() {
assert!(!find(&c, "composer:lint").is_test);
}
#[test]
fn php_pest_project() {
let dir = project(&[
("composer.json", r#"{"require-dev":{"pestphp/pest":"^3"}}"#),
("phpunit.xml", "<phpunit/>"),
("vendor/bin/phpunit", ""),
("vendor/bin/phpunit.bat", ""),
("vendor/bin/pest", ""),
("vendor/bin/pest.bat", ""),
]);
let c = detect(dir.path());
let pest = find(&c, "phpunit");
assert_eq!(pest.name, "Pest");
assert_eq!(cmdline(pest), if cfg!(windows) { "vendor/bin/pest.bat" } else { "vendor/bin/pest" });
assert_eq!(pest.kind, "phpunit");
assert!(pest.is_test);
assert_eq!(pest.debug_adapter.as_deref(), Some("php-debug"));
let dir = project(&[("composer.json", r#"{"require-dev":{"pestphp/pest":"^3"}}"#)]);
assert_eq!(find(&detect(dir.path()), "phpunit").name, "PHPUnit");
}
#[test]
fn php_detection_needs_a_marker() {
let dir = project(&[("index.php", "<?php")]);
+62 -30
View File
@@ -23,38 +23,61 @@ fn wait_exit(events: &Receiver<IdeEvent>, id: RunId, timeout: Duration) -> Optio
let deadline = Instant::now() + timeout;
loop {
let left = deadline.saturating_duration_since(Instant::now());
match events.recv_timeout(left) {
Ok(IdeEvent::RunExited { run, code }) if run == id => return code,
Ok(_) => {}
Err(_) => return None,
match events.recv_timeout(left).expect("RunExited event") {
IdeEvent::RunExited { run, code } if run == id => return code,
_ => {}
}
}
}
fn wait_for(what: &str, mut ready: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while !ready() {
assert!(Instant::now() < deadline, "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(20));
}
}
fn assert_still_running(events: &Receiver<IdeEvent>, timeout: Duration) {
let event = events.recv_timeout(timeout);
assert!(
matches!(event, Err(mpsc::RecvTimeoutError::Timeout)),
"run exited before shutdown was released: {event:?}"
);
}
#[test]
fn a_stopped_run_keeps_its_port_until_the_group_is_gone() {
let tmp = tempfile::tempdir().unwrap();
let (sink, events) = channel_sink();
let pm = ProcessManager::new();
// The `mvn`-starts-a-JVM shape: the direct child dies on SIGINT while the process it
// started ignores it and keeps its socket open for a moment.
// started ignores it and keeps its socket open until the test releases it.
let script = tmp.path().join("hold.py");
std::fs::write(
&script,
"import signal, socket, sys, time\n\
"import pathlib, signal, socket, sys, time\n\
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
s = socket.socket(); s.bind(('127.0.0.1', 0)); s.listen()\n\
open(sys.argv[1], 'w').write(str(s.getsockname()[1]))\n\
time.sleep(2)\n",
while not pathlib.Path(sys.argv[2]).exists(): time.sleep(0.01)\n",
)
.unwrap();
let port_file = tmp.path().join("port");
let release_file = tmp.path().join("release");
let config = RunConfig {
id: "boot".into(),
name: "boot".into(),
kind: "shell".into(),
command: "/bin/sh".into(),
args: vec!["-c".into(), format!("python3 {} {} & sleep 60", script.display(), port_file.display())],
args: vec![
"-c".into(),
"python3 \"$1\" \"$2\" \"$3\" & sleep 60".into(),
"sh".into(),
script.display().to_string(),
port_file.display().to_string(),
release_file.display().to_string(),
],
cwd: None,
env: BTreeMap::new(),
detected: false,
@@ -64,18 +87,24 @@ fn a_stopped_run_keeps_its_port_until_the_group_is_gone() {
extras: Default::default(),
};
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
let deadline = Instant::now() + Duration::from_secs(10);
while std::fs::read_to_string(&port_file).is_err() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
wait_for("the listening port", || {
std::fs::read_to_string(&port_file).ok().is_some_and(|text| text.trim().parse::<u16>().is_ok())
});
let port: u16 = std::fs::read_to_string(&port_file).expect("port").trim().parse().unwrap();
let stopped = Instant::now();
pm.stop(info.id).unwrap();
let pid = info.pid.expect("child pid") as libc::pid_t;
wait_for("the direct child to exit", || unsafe { libc::kill(pid, 0) != 0 });
let exit = events.recv_timeout(Duration::from_secs(1));
let port_held = std::net::TcpStream::connect(("127.0.0.1", port)).is_ok();
std::fs::write(&release_file, "").unwrap();
assert!(
matches!(exit, Err(mpsc::RecvTimeoutError::Timeout)),
"run exited while its descendant held the port: {exit:?}"
);
assert!(port_held, "the descendant still holds its port");
let code = wait_exit(&events, info.id, Duration::from_secs(20));
let elapsed = stopped.elapsed();
assert!(code.is_some(), "the run reports its exit");
assert!(elapsed >= Duration::from_millis(1500), "exit came {elapsed:?} after Stop, before the port was free");
assert!(
std::net::TcpStream::connect(("127.0.0.1", port)).is_err(),
"the port is still bound when the run reports finished"
@@ -87,15 +116,16 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
let tmp = tempfile::tempdir().unwrap();
let (sink, events) = channel_sink();
let pm = ProcessManager::new();
// Ignores SIGINT, then exits by itself after 2 s, like a slow Spring Boot shutdown: a first
// Stop waits for it instead of killing it (exit code 137).
// Ignores SIGINT until stdin releases the shutdown, like a slow Spring Boot shutdown.
// A first Stop must wait even past the grace period instead of killing it (exit code 137).
let script = tmp.path().join("slow.py");
let ready_file = tmp.path().join("ready");
std::fs::write(
&script,
"import signal, sys, time\n\
"import pathlib, signal, sys\n\
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
print('ready', flush=True)\n\
time.sleep(2)\n",
pathlib.Path(sys.argv[1]).touch()\n\
sys.stdin.readline()\n",
)
.unwrap();
let config = RunConfig {
@@ -103,7 +133,7 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
name: "slow".into(),
kind: "shell".into(),
command: "python3".into(),
args: vec![script.display().to_string()],
args: vec![script.display().to_string(), ready_file.display().to_string()],
cwd: None,
env: BTreeMap::new(),
detected: false,
@@ -113,12 +143,12 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
extras: Default::default(),
};
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
std::thread::sleep(Duration::from_millis(500)); // let the program install its handler
let start = Instant::now();
pm.stop(info.id).unwrap();
wait_for("the SIGINT handler", || ready_file.exists());
pm.stop_with_grace(info.id, Duration::from_millis(100)).unwrap();
assert_still_running(&events, Duration::from_secs(1));
pm.write_stdin(info.id, "finish shutdown\n").unwrap();
let code = wait_exit(&events, info.id, Duration::from_secs(10));
assert_eq!(code, Some(0), "the program exited by itself, not by SIGKILL");
assert!(start.elapsed() >= Duration::from_millis(1500), "stop waited for the shutdown, not a timer");
}
#[test]
@@ -127,11 +157,13 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
let (sink, events) = channel_sink();
let pm = ProcessManager::new();
let script = tmp.path().join("stuck.py");
let ready_file = tmp.path().join("ready");
std::fs::write(
&script,
"import signal, time\n\
"import pathlib, signal, sys\n\
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
time.sleep(60)\n",
pathlib.Path(sys.argv[1]).touch()\n\
sys.stdin.readline()\n",
)
.unwrap();
let config = RunConfig {
@@ -139,7 +171,7 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
name: "stuck".into(),
kind: "shell".into(),
command: "python3".into(),
args: vec![script.display().to_string()],
args: vec![script.display().to_string(), ready_file.display().to_string()],
cwd: None,
env: BTreeMap::new(),
detected: false,
@@ -149,9 +181,9 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
extras: Default::default(),
};
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
std::thread::sleep(Duration::from_millis(500));
wait_for("the SIGINT handler", || ready_file.exists());
pm.stop(info.id).unwrap();
std::thread::sleep(Duration::from_millis(200));
assert_still_running(&events, Duration::from_millis(200));
pm.stop(info.id).unwrap();
let code = wait_exit(&events, info.id, Duration::from_secs(10));
assert_eq!(code, Some(137), "the second Stop SIGKILLs");
+12
View File
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
pub use text_encoding::TextEncoding;
pub use write::PendingChange;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -1069,6 +1070,17 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::commit(self, message, options, &|path| encoding(path).map(Into::into))
}
/// What [`Repo::commit_with`] would commit with `options`, file by file, without
/// committing or touching the index: patches against HEAD, `max_bytes` in all.
pub fn pending_changes<E: Into<TextEncoding>>(
&self,
options: &CommitOptions,
max_bytes: usize,
encoding: &dyn Fn(&Path) -> IdeResult<E>,
) -> IdeResult<Vec<PendingChange>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::pending_changes(self, options, max_bytes, &|path| encoding(path).map(Into::into))
}
/// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch
/// (`origin/x`) gets a local tracking branch and any other revision is checked out
/// detached.
+16 -2
View File
@@ -15,6 +15,11 @@ pub(crate) struct Cache {
repo: gix::ThreadSafeRepository,
files: [PathBuf; 4],
stamp: Vec<Option<(SystemTime, u64)>>,
/// `objects/pack` directory and its modification time. An external
/// `git fetch`/`pull`/`repack` adds packs without touching the `files`
/// above — watching the directory catches them (issue #1919).
pack_dir: PathBuf,
pack_stamp: Option<SystemTime>,
}
fn cache_files(git_dir: &Path, common_dir: &Path) -> [PathBuf; 4] {
@@ -36,6 +41,12 @@ fn stamp(files: &[PathBuf]) -> Vec<Option<(SystemTime, u64)>> {
.collect()
}
/// The pack directory's modification time: adding or removing a pack file
/// updates it. `None` when the directory can't be read.
fn pack_stamp(pack_dir: &Path) -> Option<SystemTime> {
std::fs::metadata(pack_dir).ok()?.modified().ok()
}
pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
let start = if path.is_file() { path.parent().unwrap_or(path) } else { path };
if let Err(e) = gix::discover::upwards(start) {
@@ -57,7 +68,8 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
let git_dir = absolute(repo.git_dir())?;
let common_dir = absolute(repo.common_dir())?;
let files = cache_files(&git_dir, &common_dir);
let cache = Cache { stamp: stamp(&files), files, repo: ts };
let pack_dir = common_dir.join("objects").join("pack");
let cache = Cache { stamp: stamp(&files), files, repo: ts, pack_stamp: pack_stamp(&pack_dir), pack_dir };
Ok(Some(Repo {
execution_allowed: std::sync::atomic::AtomicBool::new(true),
root,
@@ -96,11 +108,13 @@ impl Repo {
}
let mut cache = self.inner.lock().unwrap_or_else(|p| p.into_inner());
let now = stamp(&cache.files);
if now != cache.stamp {
let pack_now = pack_stamp(&cache.pack_dir);
if now != cache.stamp || pack_now != cache.pack_stamp {
match gix::ThreadSafeRepository::open(&self.git_dir) {
Ok(fresh) => {
cache.repo = fresh;
cache.stamp = now;
cache.pack_stamp = pack_now;
}
Err(e) => tracing::warn!("reopening repository failed: {e}"),
}
+140 -9
View File
@@ -372,10 +372,10 @@ pub(crate) fn commit(
args.extend(["-F", "-"]);
Some(message.as_bytes())
};
if options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty() {
run(r.root(), &args, stdin)?;
} else {
if selects(options) {
commit_selected(r, &args, stdin, options, encoding)?;
} else {
run(r.root(), &args, stdin)?;
}
// A commit concluding a merge brings back the local changes put aside for it.
let _ = crate::preserve::restore(r);
@@ -393,9 +393,13 @@ impl Drop for TempIndex {
}
}
/// Whether `options` commit something else than the index as it is.
fn selects(options: &CommitOptions) -> bool {
!(options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty())
}
/// Commits a selection of the changes through a copy of the index (`GIT_INDEX_FILE`):
/// `exclude` goes back to HEAD in the copy, `stage` takes its working-tree content and
/// `partial` only the chosen hunks. The working tree and the user's index are left alone
/// see [`select_into`]. The working tree and the user's index are left alone
/// except that the committed files' entries end up matching the new commit, so changes
/// left out keep their staged state.
fn commit_selected(
@@ -406,6 +410,23 @@ fn commit_selected(
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<()> {
let tmp = TempIndex(r.git_dir.join("openidea-partial-index"));
let env = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
let rels = select_into(r, &tmp, options, encoding)?;
cli::run_env(r.root(), commit_args, stdin, &env)?;
// The user's index still holds the partial files' previous entries: align them with
// the commit so the remaining changes show as unstaged, nothing as staged.
run_paths(r, &["reset", "-q", "HEAD"], &rels)
}
/// Fills `tmp`, a copy of the index, with what a commit of `options` holds: `exclude`
/// goes back to HEAD, `stage` takes its working-tree content and `partial` only the
/// chosen hunks. Returns the root-relative paths of `stage` and `partial`.
fn select_into(
r: &Repo,
tmp: &TempIndex,
options: &CommitOptions,
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<Vec<String>> {
let index = r.git_dir.join("index");
if index.exists() {
std::fs::copy(&index, &tmp.0)?;
@@ -459,10 +480,120 @@ fn commit_selected(
write_index_blob(r, &rel, &bytes, &env)?;
rels.push(rel);
}
cli::run_env(r.root(), commit_args, stdin, &env)?;
// The user's index still holds the partial files' previous entries: align them with
// the commit so the remaining changes show as unstaged, nothing as staged.
run_paths(r, &["reset", "-q", "HEAD"], &rels)
Ok(rels)
}
/// The changes a commit of `options` would make, as `git diff` reports them (renames
/// detected), from HEAD (an empty tree without one). Nothing is committed and the
/// working tree and the index are left alone: a selection is made in a copy of the
/// index. Patches stop growing at `max_bytes` in all.
pub(crate) fn pending_changes(
r: &Repo,
options: &CommitOptions,
max_bytes: usize,
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<Vec<PendingChange>> {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = TempIndex(r.git_dir.join(format!("openidea-pending-index-{}-{n}", std::process::id())));
let env_tmp = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
let env: &[(&str, &std::ffi::OsStr)] = if selects(options) {
select_into(r, &tmp, options, encoding)?;
&env_tmp
} else {
&[]
};
// `--cached` compares with HEAD, or with an empty tree on an unborn branch.
let base = ["diff", "--cached", "-M", "--no-ext-diff", "--no-textconv", "--no-color", "--no-relative"];
let names = cli::run_env(r.root(), &[&base[..], &["--name-status", "-z"]].concat(), None, env)?;
let patch = cli::run_env(
r.root(),
&[&base[..], &["--patch", "--src-prefix=a/", "--dst-prefix=b/", "--full-index"]].concat(),
None,
env,
)?;
let mut changes = parse_name_status(&names.stdout, r.root());
let sections = split_patch(&patch.stdout);
let mut budget = max_bytes;
for (change, section) in changes.iter_mut().zip(sections) {
if patch_is_binary(section) {
continue;
}
let charset = encoding(&change.path).unwrap_or_else(|_| encoding_rs::UTF_8.into());
let text = crate::text_encoding::decode_raw(section, charset);
if text.len() <= budget {
budget -= text.len();
change.diff = Some(text.into_owned());
} else {
let mut cut = budget.min(text.len());
// Whole lines only, at a character boundary.
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
let kept = text[..cut].rfind('\n').map_or(0, |i| i + 1);
budget -= kept;
change.diff = Some(text[..kept].to_owned());
change.truncated = true;
}
}
Ok(changes)
}
/// A file a pending commit changes ([`Repo::pending_changes`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingChange {
pub path: PathBuf,
/// The path before a rename or copy.
pub old_path: Option<PathBuf>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
pub status: &'static str,
/// Its section of the unified diff; `None` for binary content.
pub diff: Option<String>,
/// `diff` was cut at the size limit.
pub truncated: bool,
}
/// `git diff --name-status -z`: `M\0path\0`, `R100\0old\0new\0`.
fn parse_name_status(out: &[u8], root: &Path) -> Vec<PendingChange> {
let mut fields = out.split(|b| *b == 0).filter(|f| !f.is_empty());
let path = |f: &[u8]| root.join(String::from_utf8_lossy(f).as_ref());
let mut changes = Vec::new();
while let Some(code) = fields.next() {
let status = match code.first() {
Some(b'A') => "added",
Some(b'D') => "deleted",
Some(b'R') => "renamed",
Some(b'C') => "copied",
Some(b'T') => "type-changed",
_ => "modified",
};
let two = matches!(code.first(), Some(b'R' | b'C'));
let first = fields.next().map(path);
let (old_path, path) = if two { (first, fields.next().map(path)) } else { (None, first) };
let Some(path) = path else { break };
changes.push(PendingChange { path, old_path, status, diff: None, truncated: false });
}
changes
}
/// The per-file sections of a patch, each starting at its `diff --git` line.
fn split_patch(patch: &[u8]) -> Vec<&[u8]> {
let mut starts: Vec<usize> = Vec::new();
let mut at = 0;
for line in patch.split_inclusive(|b| *b == b'\n') {
if line.starts_with(b"diff --git ") {
starts.push(at);
}
at += line.len();
}
starts.iter().enumerate().map(|(i, &s)| &patch[s..starts.get(i + 1).copied().unwrap_or(patch.len())]).collect()
}
fn patch_is_binary(section: &[u8]) -> bool {
section
.split(|b| *b == b'\n')
.take_while(|l| !l.starts_with(b"@@"))
.any(|l| l.starts_with(b"Binary files ") || l.starts_with(b"GIT binary patch"))
}
pub(crate) fn checkout(r: &Repo, branch: &str, create: bool) -> IdeResult<()> {
+89
View File
@@ -1203,6 +1203,71 @@ fn partial_commit_of_chosen_hunks_leaves_worktree_and_index_alone() {
assert_eq!(fx.read("f.txt"), "Z\nb\nc\nd\ne\nf\ng\nh\ni\nJ\nk\nl\n");
}
#[test]
fn pending_changes_show_what_the_commit_would_hold_without_touching_the_index() {
let fx = Fixture::new();
let base = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
fx.write("f.txt", base);
fx.write("staged.txt", "s\n");
fx.write("old.txt", "moved\nfile\nwith\nlines\n");
fx.write("bin.dat", "\0\x01");
fx.commit_all("base");
let repo = fx.repo();
let utf8 = |_: &Path| -> ide_core::IdeResult<&encoding_rs::Encoding> { Ok(encoding_rs::UTF_8) };
let edited = "A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
fx.write("f.txt", edited);
fx.write("staged.txt", "s2\n");
fx.git(&["add", "staged.txt"]);
fx.git(&["mv", "old.txt", "new.txt"]);
fx.write("bin.dat", "\0\x02");
fx.write("added.txt", "new\n");
let index_before = std::fs::read(fx.dir.join(".git/index")).unwrap();
// The index as it is: the staged file and the rename.
let plain = repo.pending_changes(&CommitOptions::default(), 1 << 20, &utf8).unwrap();
let summary: Vec<_> = plain
.iter()
.map(|c| (c.status, c.path.strip_prefix(&fx.dir).unwrap().to_string_lossy().into_owned()))
.collect();
assert_eq!(summary, [("renamed", "new.txt".to_owned()), ("modified", "staged.txt".to_owned())]);
assert_eq!(plain[0].old_path.as_deref(), Some(fx.path("old.txt").as_path()));
assert!(plain[1].diff.as_deref().unwrap().contains("-s\n+s2\n"), "{:?}", plain[1].diff);
// A selection: the first hunk of f.txt, the binary and the new file, staged.txt left out.
let hunks = diff_hunks(base, edited);
let options = CommitOptions {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
stage: vec![fx.path("bin.dat").into(), fx.path("added.txt").into()],
exclude: vec![fx.path("staged.txt").into()],
..Default::default()
};
let picked = repo.pending_changes(&options, 1 << 20, &utf8).unwrap();
let by_name = |name: &str| picked.iter().find(|c| c.path == fx.path(name)).unwrap_or_else(|| panic!("{name}"));
assert!(picked.iter().all(|c| c.path != fx.path("staged.txt")));
assert_eq!(by_name("added.txt").status, "added");
assert_eq!(by_name("bin.dat").diff, None);
let f = by_name("f.txt").diff.clone().unwrap();
assert!(f.starts_with("diff --git a/f.txt b/f.txt\n"), "{f}");
assert!(f.contains("-a\n+A\n") && !f.contains("+J"), "{f}");
// Nothing changed: the index, the working tree, no temporary index left behind.
assert_eq!(std::fs::read(fx.dir.join(".git/index")).unwrap(), index_before);
assert_eq!(fx.read("f.txt"), edited);
let leftovers: Vec<_> = std::fs::read_dir(fx.dir.join(".git"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("openidea-"))
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
// The size limit cuts at a line and says so.
let cut = repo.pending_changes(&options, 40, &utf8).unwrap();
let total: usize = cut.iter().filter_map(|c| c.diff.as_ref()).map(String::len).sum();
assert!(total <= 40 && cut.iter().any(|c| c.truncated));
assert!(cut.iter().filter_map(|c| c.diff.as_ref()).all(|d| d.is_empty() || d.ends_with('\n')));
}
#[test]
fn partial_staging_with_built_patch() {
let fx = Fixture::new();
@@ -1366,6 +1431,30 @@ fn status_reflects_external_changes_without_reopen() {
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("x"));
}
#[test]
fn branches_in_newly_written_packs_stay_visible() {
let fx = Fixture::new();
fx.write("a", "1\n");
fx.commit_all("a");
let repo = fx.repo();
assert_eq!(repo.branches().unwrap().len(), 1);
// An external pull: objects move into a pack the IDE has never seen, then a
// new branch points at a commit living only in that pack.
fx.write("a", "2\n");
fx.commit_all("b");
fx.git(&["repack", "-adq"]);
fx.git(&["checkout", "-q", "-b", "uat"]);
let uat = fx.git(&["rev-parse", "uat"]);
assert!(std::fs::read_dir(fx.path(".git/objects/pack")).unwrap().count() >= 2);
let names: Vec<String> = repo.branches().unwrap().into_iter().map(|b| b.name).collect();
assert!(names.contains(&"uat".to_string()), "branches: {names:?}, want uat at {uat}");
// Status peels HEAD for the tree-index diff: the same failure drops it too.
assert!(repo.status().is_ok(), "status failed: {:?}", repo.status().unwrap_err());
}
#[test]
fn linked_worktree_and_submodule() {
let fx = Fixture::new();
+87 -5
View File
@@ -117,6 +117,13 @@ impl Watcher {
self.polling.as_deref()
}
/// A watch failure past startup (the OS watch limit ran out mid-tree):
/// directories past the limit never sync (issue #1915). `None` while every
/// directory has a live watch.
pub fn unwatched_reason(&self) -> Option<String> {
self.inner.state.exhausted.lock().clone()
}
/// Replaces the exclude globs (Settings › Project Structure / File Types
/// changed): later events below newly excluded folders are dropped. In
/// per-directory mode, folders no longer excluded are watched once they change.
@@ -163,6 +170,7 @@ impl Watcher {
queue: Mutex::default(),
wake: Condvar::new(),
stop: AtomicBool::new(false),
exhausted: Mutex::new(None),
});
let weak: Weak<DirState> = Arc::downgrade(&state);
let watcher = W::new(
@@ -234,8 +242,9 @@ const MAX_WATCHES_HINT: &str = "the inotify watch limit is exhausted (fs.inotify
/// instance per watcher (`EMFILE`, os error 24) or one watch per directory
/// (`ENOSPC`, mapped by notify to [`ErrorKind::MaxFilesWatch`]). A bare OS
/// error sends users off to search engines otherwise (issue #788).
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
let hint = match error.kind {
/// The fix hint when an OS watch limit is hit, or `None` for other errors.
fn watch_limit_hint(error: &notify::Error) -> Option<&'static str> {
match error.kind {
ErrorKind::MaxFilesWatch => Some(MAX_WATCHES_HINT),
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(24) => Some(
"the inotify instance limit is exhausted (fs.inotify.max_user_instances); raise it, e.g. \
@@ -243,8 +252,11 @@ fn watcher_error(what: &str, error: notify::Error) -> IdeError {
),
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(28) => Some(MAX_WATCHES_HINT),
_ => None,
};
match hint {
}
}
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
match watch_limit_hint(&error) {
Some(hint) => io_error(format!("{what}: {error}; {hint}")),
None => io_error(format!("{what}: {error}")),
}
@@ -259,6 +271,10 @@ struct DirState {
queue: Mutex<Vec<(notify::Event, Instant)>>,
wake: Condvar,
stop: AtomicBool,
/// Set when a `watch()` call reports the OS watch limit (inotify
/// `ENOSPC`/`EMFILE`): later directories are never seen, so the project is
/// silently stale past that point — surfaced via [`Watcher::unwatched_reason`].
exhausted: Mutex<Option<String>>,
}
impl DirState {
@@ -362,7 +378,15 @@ impl DirState {
true
}
Err(e) => {
tracing::debug!("cannot watch {}: {e}", dir.display());
if let Some(hint) = watch_limit_hint(&e) {
tracing::warn!("cannot watch {}: {e}", dir.display());
// Recorded, not delivered: the watch may fail after start,
// on the debounce thread — the UI picks it up via
// `unwatched_reason()`.
*self.exhausted.lock() = Some(format!("cannot watch {}: {e}; {hint}", dir.display()));
} else {
tracing::debug!("cannot watch {}: {e}", dir.display());
}
false
}
}
@@ -1320,4 +1344,62 @@ mod tests {
let other = notify::Error::io(std::io::Error::from_raw_os_error(13));
assert!(!watcher_error("cannot start file watcher", other).message.contains("inotify"));
}
/// Watch calls succeed until `budget` is spent, then `ENOSPC` — mirrors
/// `fs.inotify.max_user_watches` running out part-way through a tree.
struct ExhaustedWatcher {
budget: std::sync::atomic::AtomicUsize,
}
impl notify::Watcher for ExhaustedWatcher {
fn new<F: notify::EventHandler>(_: F, _: notify::Config) -> notify::Result<Self> {
Ok(Self { budget: std::sync::atomic::AtomicUsize::new(3) })
}
fn watch(&mut self, _dir: &Path, _: RecursiveMode) -> notify::Result<()> {
if self.budget.fetch_sub(1, Ordering::SeqCst) == 0 {
return Err(notify::Error::new(ErrorKind::MaxFilesWatch));
}
Ok(())
}
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
Ok(())
}
fn kind() -> WatcherKind {
WatcherKind::NullWatcher
}
}
/// #1915: a watch refused mid-tree must not stay silent — the limit is
/// reported through `unwatched_reason` so the UI can warn like the
/// polling fallback does.
#[test]
fn unwatched_reason_reports_the_watch_limit() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
for d in ["a", "b", "c", "d"] {
std::fs::create_dir_all(root.join(d)).unwrap();
}
let filter = EventFilter::new(root.clone(), &[]);
let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone());
let (tx, _rx) = std::sync::mpsc::channel::<FsBatch>();
let emit: Emit = Arc::new(move |b| {
let _ = tx.send(b.clone());
});
let inner = Watcher::start_queued::<ExhaustedWatcher>(
&root,
filter,
emit,
WatchMode::PerDirectory,
notify::Config::default(),
)
.unwrap();
let watcher = Watcher { inner, excludes, rules, polling: None };
assert!(watcher.polling().is_none(), "the OS watcher did start");
let reason = watcher.unwatched_reason().expect("watch limit must be reported");
assert!(reason.contains("max_user_watches"), "{reason}");
assert!(reason.contains("cannot watch"), "{reason}");
}
}
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "./fixtures";
import type { Page } from "@playwright/test";
async function placeCaret(page: Page, marker: string) {
await page.locator(".cm-content").evaluate((element, marker) => {
const view = (element as HTMLElement & { cmTile: { view: import("@codemirror/view").EditorView } }).cmTile.view;
const anchor = view.state.doc.toString().indexOf(marker) + marker.length;
view.dispatch({ selection: { anchor } });
view.focus();
}, marker);
}
test("Angular templates highlight and close tags in inline and external documents", async ({ workbench: page }) => {
await page.evaluate(async () => {
const backendPath = "/src/editor/core/backend.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { docBackend } = await import(backendPath);
const { editorService } = await import(contributionsPath);
const files = docBackend().files;
files.set("/dev/acme-api/angular.json", "{}");
files.set(
"/dev/acme-api/app.ts",
'@Component({\n template: `<div [title]="name">{{ name }}</div>\n @if (ready) { <p>Ready</p> }\n <r-card\n <section\n <article><\n `\n})\nexport class App {}\n',
);
files.set("/dev/acme-api/app.html", "<r-card\n<div\n<article><");
files.set("/dev/acme-api/plain.ts", "const data = { template: `<r-card/>` };");
await editorService().openFile("/dev/acme-api/app.ts");
});
const inline = page.locator('.cm-editor[data-language="Angular TypeScript"]');
await expect(inline).toBeVisible();
await expect(inline.locator(".cm-content span").filter({ hasText: /^div$/ }).first()).toBeVisible();
await expect(inline.locator(".cm-content span").filter({ hasText: /^@if$/ })).toBeVisible();
for (const [marker, text, result] of [
["<r-card", "/", "<r-card/>"],
["<section", ">", "<section></section>"],
["<article><", "/", "<article></article>"],
]) {
await placeCaret(page, marker!);
await page.keyboard.type(text!);
await expect(inline.locator(".cm-content")).toContainText(result!);
}
await page.evaluate(async () => {
const path = "/src/workbench/contributions.ts";
await (await import(path)).editorService().openFile("/dev/acme-api/app.html");
});
const external = page.locator('.cm-editor[data-language="Angular Template"]');
await expect(external).toBeVisible();
for (const [marker, text, result] of [
["<r-card", "/", "<r-card/>"],
["<div", ">", "<div></div>"],
["<article><", "/", "<article></article>"],
]) {
await placeCaret(page, marker!);
await page.keyboard.type(text!);
await expect(external.locator(".cm-content")).toContainText(result!);
}
await page.evaluate(async () => {
const path = "/src/workbench/contributions.ts";
await (await import(path)).editorService().openFile("/dev/acme-api/plain.ts");
});
await expect(page.locator(".cm-content span").filter({ hasText: /^r-card$/ })).toHaveCount(0);
});
+10
View File
@@ -0,0 +1,10 @@
id = "arduino"
name = "Arduino"
# Sketches are C++ with an implicit `Arduino.h` and generated prototypes; clangd can't compile
# them on its own, so they get C++ highlighting without a language server.
extensions = ["ino"]
line-comment = "//"
block-comment = ["/*", "*/"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "C++"
tree-sitter = "cpp"
+1
View File
@@ -3,6 +3,7 @@ name = "Go Module"
filenames = ["go.mod"]
line-comment = "//"
indent = { tab-size = 4, insert-spaces = false }
ui-mode = "GoMod"
[[language-servers]]
name = "gopls"
+1
View File
@@ -3,6 +3,7 @@ name = "Go Workspace"
filenames = ["go.work"]
line-comment = "//"
indent = { tab-size = 4, insert-spaces = false }
ui-mode = "GoMod"
[[language-servers]]
name = "gopls"
+4
View File
@@ -8,6 +8,10 @@ indent = { tab-size = 2, insert-spaces = true }
ui-mode = "TypeScript"
tree-sitter = "typescript"
[[framework-ui-modes]]
ui-mode = "Angular TypeScript"
root-markers = ["angular.json", "node_modules/@angular/core"]
[[language-servers]]
name = "vtsls"
tsserver-project = true
+98 -33
View File
@@ -34,7 +34,7 @@ plugins/
├─ regex-tester/ # html view talking to its code, settings, the editor's selection
└─ license-header/ # file-opened events, quick fix, project scan in a task
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:[email protected]`, world
@@ -137,23 +137,24 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
### Contribution points
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| Point | Declaration | Code needed |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes |
A `viewItem.context` menu needs `view`, the id of one of the plugin's own
views. It adds the commands to the right-click menu of the items in that
@@ -174,6 +175,10 @@ command = "kube.logs"
when = "viewItemKind == pod"
```
A `commit.message` menu puts the command, with the plugin's icon, in the
toolbar above the commit message of the Commit tool window and the Commit
dialog (see _Commits_). It is new in API 1.3 (`engines.openidea = "^1.3"`).
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
themes can be applied, settings pages are there — **before** any plugin code
@@ -227,20 +232,20 @@ installing (or when a new version asks for more). Low-risk capabilities are
listed; high-risk ones are called out as equivalent to running code on the
machine. Users can revoke optional capabilities in Settings › Plugins.
| Capability | Risk | Unlocks today |
| ------------------- | ------ | ----------------------------------------------------------------------------------- |
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `editor.read` | low | `TextEditor::document`, `selections` |
| `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` |
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
| `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events |
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
| Capability | Risk | Unlocks today |
| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `editor.read` | low | `TextEditor::document`, `selections` |
| `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes`, `host::pending_commit`, before-commit checks |
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` |
| `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events |
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
`process:` capabilities are also derived from what the contributions start: a
language's `language-servers` and `debugger` commands and a run detector's
@@ -349,6 +354,7 @@ then run `openidea-plugin check`.
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
| `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. |
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
@@ -395,6 +401,45 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
### Commits
A plugin can work on the commit the user is preparing, in the Commit tool
window or the Commit dialog (API 1.3, `engines.openidea = "^1.3"`):
- **A button above the message.** `[[contributes.menus]]` with
`menu = "commit.message"` shows one of the plugin's commands there, with the
plugin's icon.
- **The pending commit.** `host::pending_commit()` (`vcs.read`) returns what
Commit would commit right now: per file its repository, path, status and a
`git diff` of only the included changes (the checked files and changed
blocks, against HEAD; 1 MiB in all, then `truncated`), plus the message,
Amend and a `revision`. Unsaved editors of those files are saved first, as
Commit does; nothing is staged or committed. `None` without a repository.
- **The message.** `host::set_commit_message(revision, text)` (`vcs.write`)
replaces it only while the draft is still at `revision`. If the user typed
or changed the included changes meanwhile, it fails with `Error::Stale` and
nothing is overwritten. The user reviews the text and commits.
Both work from a command or event of a window, in a trusted project; they wait
for the window, which isn't charged to the time limit. A message generator
calls a model with `host::fetch` and a `{{secret:…}}` key (see _Network_)
inside a task:
```rust
let draft = host::pending_commit()?.ok_or("no repository")?;
let message = ask_the_model(&draft.changes)?;
host::set_commit_message(draft.revision, &message)?;
```
**Before-commit checks.** Each `[[contributes.commitChecks]]` (`id`, `title`)
is listed in Commit Options, on by default. When the user commits, after the
IDE's own checks, the IDE calls the optional `commit-checker` export
(`register_plugin!(MyPlugin, commit_checks)`, trait `commit::CommitChecks`)
with the check's id and the draft. Problems (`message`, `location?`) stop the
commit and show beside the message with the check's title, until the user
changes it or chooses Commit Anyway. A check that fails, times out or lacks
`vcs.read` is reported as a problem too.
### Providers
Declare what the plugin provides in the editor; only plugins whose
@@ -402,7 +447,7 @@ declaration matches the file's language are called (and loaded):
```toml
[[contributes.providers]]
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens"
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover"
languages = ["java", "xml"] # editor language ids; "*" for any
```
@@ -457,6 +502,26 @@ the language server's semantic tokens and win where they overlap one, so the
editor's colour scheme styles them like the server's. They are asked again
after each edit, under the edit-path budget; at most 65,536 per document.
#### Hover
The `hover` kind (API 1.3) adds Markdown to the hover of documents of the
declared languages, here too including languages the plugin doesn't
contribute. Register with `register_plugin!(MyPlugin, hover)` and implement
`openidea_plugin::hover::HoverProvider`:
```rust
impl HoverProvider for MyPlugin {
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
Some(Hover::new("**orders.index** · `GET /orders`"))
}
}
```
The host asks the language server and the plugins together, under the
edit-path budget, and shows the server's hover first, then each plugin's
below a rule. `range`, when given, is the text the hover describes (used when
the server names none). At most 64 KiB of Markdown per plugin.
### Network and secrets
`host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
eb76da4bc9db84dcbad34143bac18c9ecf974276f793009791a15aab2eee291d
89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0a19948927a23de81b2a453b9992e021744093cbe41ea92daa9feb2b1bc9a2a9
a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
564bfd00694f27f35f09eb0b81d341207a755528b5fe7c46c96717b3868d0f65
7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
f3f447366fa38f9bf4911f35b6e899405b0e072ceee3ad5ea2fe90639d70fa53
d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
85cad466adb94f2b58e62263617e885d1c47cf70db5e8b38942bd415f0d779af
511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
659446eda0b50aa0fb08f6af68f7d355fcaf3342847e2e579999dcf52a7a7399
8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
51ef9d43315ca9d7239999c222fbf7a2c01bc85c2b40f53f34a1a8e5d31baa6d
6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
bdf9455c6e7c8c340c54d783f351220b38cffed36907e1b5a1f2e11801e6f5bb
43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7e4d00e1ea63b2eca48b9297667e04c05a9f9832f1dee2262272801d516b9a16
3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
00cbd2dd7f0937554633ba5e533f19be85f70579c7c6fedb6fe0228f8f63bf4c
588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
eecf2cf561dd5e3e3edbf9f67ccb479573dd538a75d4426a1d086edf0a310043
723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3
+38
View File
@@ -0,0 +1,38 @@
//! Before-commit checks (world `plugin-commit-checks`): declare each under
//! `[[contributes.commitChecks]]` and register the export with
//! `register_plugin!(MyPlugin, commit_checks)`. They run when the user commits
//! (after the IDE's own checks, for the ones turned on in Commit Options);
//! problems stop the commit until the user changes it or chooses Commit Anyway.
//! Needs `vcs.read`.
//!
//! To read or set the message of the commit being prepared from a command, see
//! [`crate::host::pending_commit`] and [`crate::host::set_commit_message`].
use crate::{CommitDraft, Error, Invocation, Location};
/// Something the user should look at before committing.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommitProblem {
/// One sentence, shown after the check's title.
pub message: String,
/// Where it is, opened from the problem; `None` for the commit as a whole.
pub location: Option<Location>,
}
impl CommitProblem {
pub fn new(message: impl Into<String>) -> Self {
Self { message: message.into(), location: None }
}
pub fn at(mut self, location: Location) -> Self {
self.location = Some(location);
self
}
}
/// The plugin's before-commit checks.
pub trait CommitChecks {
/// Runs the declared check `check` on `draft`: no problems lets the commit
/// go on. An error is reported as a problem of the check.
fn check(&mut self, check: &str, draft: &CommitDraft, ctx: &Invocation) -> Result<Vec<CommitProblem>, Error>;
}
+50
View File
@@ -212,6 +212,35 @@ mod imp {
wit::vcs::remotes(p).map_err(error)
}
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
wit::commit::pending_commit().map(|d| d.map(__draft_in)).map_err(error)
}
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
wit::commit::set_commit_message(revision, message).map_err(error)
}
#[doc(hidden)]
pub fn __draft_in(d: wit::commit::CommitDraft) -> CommitDraft {
CommitDraft {
revision: d.revision,
message: d.message,
amend: d.amend,
changes: d
.changes
.into_iter()
.map(|c| CommitChange {
repository: c.repository,
path: c.path,
old_path: c.old_path,
status: c.status,
diff: c.diff,
truncated: c.truncated,
})
.collect(),
}
}
pub fn subscribe(k: EventKind) -> Result<(), Error> {
wit::events::subscribe(k.name()).map_err(error)
}
@@ -427,6 +456,27 @@ impl Project {
}
}
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub use imp::__draft_in;
/// The commit being prepared in the window the command or event came from:
/// the included files and changed blocks only, as Commit would commit them
/// (unsaved editors of those files are saved first). `None` when the window's
/// project has no repository. Only from a command or event of a window, in a
/// trusted project. Needs `vcs.read`.
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
imp::pending_commit()
}
/// Replaces the commit message of that window, only while its draft is still
/// at `revision` ([`CommitDraft::revision`]); [`Error::Stale`] when the user
/// changed the message or the included changes meanwhile, and nothing changes.
/// Needs `vcs.write`.
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
imp::set_commit_message(revision, message)
}
/// Replaces the plugin's diagnostics for `path` (absolute, in an open
/// project); an empty slice clears them. Needs `project.read`.
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
+38
View File
@@ -0,0 +1,38 @@
use crate::types::{Position, Range};
pub use crate::providers::Document;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hover {
pub markdown: String,
pub range: Option<Range>,
}
impl Hover {
pub fn new(markdown: impl Into<String>) -> Self {
Self { markdown: markdown.into(), range: None }
}
pub fn range(mut self, range: Range) -> Self {
self.range = Some(range);
self
}
}
pub trait HoverProvider {
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hover_can_name_the_range_it_describes() {
let range = Range { start: Position { line: 2, character: 4 }, end: Position { line: 2, character: 9 } };
let hover = Hover::new("**route** `orders.index`").range(range);
assert_eq!(hover.markdown, "**route** `orders.index`");
assert_eq!(hover.range, Some(range));
assert_eq!(Hover::new("x").range, None);
}
}
+104
View File
@@ -39,7 +39,9 @@
//! Build with `openidea-plugin build` (or `cargo build --release --target
//! wasm32-wasip2`); see `plugins/README.md`.
pub mod commit;
pub mod host;
pub mod hover;
pub mod indexing;
#[cfg(not(target_arch = "wasm32"))]
pub mod mock;
@@ -155,6 +157,34 @@ pub mod semantic_bindings {
});
}
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub mod hover_bindings {
wit_bindgen::generate!({
world: "hover", path: "wit", pub_export_macro: true,
export_macro_name: "__export_hover_world",
default_bindings_module: "openidea_plugin::hover_bindings",
with: { "openidea:plugin/[email protected]": crate::bindings::openidea::plugin::types },
});
}
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub mod commit_checks_bindings {
wit_bindgen::generate!({
world: "commit-checks", path: "wit", pub_export_macro: true,
export_macro_name: "__export_commit_checks_world",
default_bindings_module: "openidea_plugin::commit_checks_bindings",
with: {
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::types,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::editor,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::commit,
},
});
}
/// The plugin API version this SDK targets.
pub const API_VERSION: &str = "1.3";
@@ -215,6 +245,12 @@ macro_rules! __optional_exports {
(services, $plugin:ty, $component:ident) => {
$crate::__services_exports!($plugin, $component);
};
(hover, $plugin:ty, $component:ident) => {
$crate::__hover_exports!($plugin, $component);
};
(commit_checks, $plugin:ty, $component:ident) => {
$crate::__commit_checks_exports!($plugin, $component);
};
(syntax, $plugin:ty, $component:ident) => {
impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for $component {
fn highlights(
@@ -367,6 +403,27 @@ macro_rules! __services_exports {
};
}
/// `register_plugin!(T, commit_checks)`: `T` is also a [`commit::CommitChecks`].
#[doc(hidden)]
#[macro_export]
macro_rules! __commit_checks_exports {
($plugin:ty, $component:ident) => {
impl $crate::commit_checks_bindings::exports::openidea::plugin::commit_checker::Guest for $component {
fn check(
check: ::std::string::String,
draft: $crate::bindings::openidea::plugin::commit::CommitDraft,
ctx: &$crate::bindings::openidea::plugin::invocation::Invocation,
) -> ::core::result::Result<
::std::vec::Vec<$crate::commit_checks_bindings::exports::openidea::plugin::commit_checker::CommitProblem>,
$crate::bindings::openidea::plugin::types::Error,
> {
$crate::__rt::commit_check::<$plugin>(check, draft, ctx)
}
}
$crate::commit_checks_bindings::__export_commit_checks_world!($component with_types_in $crate::commit_checks_bindings);
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! __semantic_tokens_exports {
@@ -384,6 +441,22 @@ macro_rules! __semantic_tokens_exports {
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! __hover_exports {
($plugin:ty, $component:ident) => {
impl $crate::hover_bindings::exports::openidea::plugin::hover_provider::Guest for $component {
fn hover(
doc: $crate::hover_bindings::exports::openidea::plugin::hover_provider::Document,
at: $crate::bindings::openidea::plugin::types::Position,
) -> ::core::option::Option<$crate::hover_bindings::exports::openidea::plugin::hover_provider::HoverContent> {
$crate::__rt::hover::<$plugin>(doc, at)
}
}
$crate::hover_bindings::__export_hover_world!($component with_types_in $crate::hover_bindings);
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! __plugin_exports {
@@ -639,6 +712,16 @@ pub mod __rt {
.collect()
}
pub fn hover<T: Plugin + crate::hover::HoverProvider + Default>(
d: crate::hover_bindings::exports::openidea::plugin::hover_provider::Document,
pos: b::types::Position,
) -> Option<crate::hover_bindings::exports::openidea::plugin::hover_provider::HoverContent> {
use crate::hover_bindings::exports::openidea::plugin::hover_provider as w;
let doc = crate::providers::Document { path: d.path, language: d.language, version: d.version, text: d.text };
with::<T, _>(|p| p.hover(&doc, at(pos)))
.map(|h| w::HoverContent { markdown: h.markdown, range: h.range.map(crate::host::__range_to) })
}
// ---- views ----
use crate::views as v;
@@ -756,6 +839,27 @@ pub mod __rt {
with::<T, _>(|p| p.detail(&view, &key, &ctx)).map(|tabs| tabs.into_iter().map(tab).collect()).map_err(error_out)
}
pub fn commit_check<T: Plugin + crate::commit::CommitChecks + Default>(
check: String,
draft: b::commit::CommitDraft,
ctx: &b::invocation::Invocation,
) -> Result<
Vec<crate::commit_checks_bindings::exports::openidea::plugin::commit_checker::CommitProblem>,
b::types::Error,
> {
use crate::commit_checks_bindings::exports::openidea::plugin::commit_checker as w;
let ctx = Invocation::__new(ctx);
let draft = crate::host::__draft_in(draft);
with::<T, _>(|p| p.check(&check, &draft, &ctx))
.map(|problems| {
problems
.into_iter()
.map(|p| w::CommitProblem { message: p.message, location: p.location.map(loc) })
.collect()
})
.map_err(error_out)
}
pub fn view_event<T: Plugin + v::Views + Default>(
view: String,
event: vt::ViewEvent,
+42
View File
@@ -66,6 +66,7 @@ struct State {
tasks: Vec<MockTask>,
vcs: Option<RepositoryStatus>,
remotes: Vec<(String, String)>,
commit: Option<CommitDraft>,
fetches: u32,
fetch_error: Option<Error>,
clipboard: Option<String>,
@@ -250,6 +251,27 @@ impl MockHost {
self.state.borrow().revealed_views.clone()
}
/// The commit being prepared that [`crate::host::pending_commit`] returns
/// (`None`: no repository).
pub fn set_commit_draft(&self, draft: Option<CommitDraft>) {
self.state.borrow_mut().commit = draft;
}
/// The draft as the plugin left it: [`crate::host::set_commit_message`]
/// replaces the message and moves the revision on.
pub fn commit_draft(&self) -> Option<CommitDraft> {
self.state.borrow().commit.clone()
}
/// The user edits the draft: its revision moves on, so a later
/// [`crate::host::set_commit_message`] for the old one is stale.
pub fn edit_commit_message(&self, message: &str) {
if let Some(d) = self.state.borrow_mut().commit.as_mut() {
d.message = message.to_owned();
d.revision += 1;
}
}
/// Makes `vcs-fetch` fail.
pub fn fail_fetch(&self, error: Option<Error>) {
self.state.borrow_mut().fetch_error = error;
@@ -618,6 +640,26 @@ pub mod current {
})
}
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
with(|s| {
require(s, "vcs.read")?;
Ok(s.commit.clone())
})
}
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
with(|s| {
require(s, "vcs.write")?;
let draft = s.commit.as_mut().ok_or_else(|| Error::Invalid("no repository".into()))?;
if draft.revision != revision {
return Err(Error::Stale);
}
draft.message = message.to_owned();
draft.revision += 1;
Ok(())
})
}
pub fn subscribe(kind: EventKind) -> Result<(), Error> {
with(|s| {
let cap = match kind {
+29
View File
@@ -294,6 +294,35 @@ pub struct RepositoryStatus {
pub changed_files: u32,
}
/// A file the commit being prepared changes ([`crate::host::pending_commit`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommitChange {
/// Absolute path of the working tree root of its repository.
pub repository: String,
/// Absolute path (after a rename, the new one).
pub path: String,
/// The path before a rename or copy.
pub old_path: Option<String>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
pub status: String,
/// The included changes in `git diff` format against HEAD; `None` for binary content.
pub diff: Option<String>,
/// `diff` was cut at the size limit (1 MiB for all the changes).
pub truncated: bool,
}
/// What Commit would commit right now.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommitDraft {
/// Changes with the message, the included changes or Amend; pass it to
/// [`crate::host::set_commit_message`].
pub revision: u64,
pub message: String,
/// The commit replaces the last one; `changes` are still against HEAD.
pub amend: bool,
pub changes: Vec<CommitChange>,
}
/// A range in a file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Location {
+75
View File
@@ -0,0 +1,75 @@
package openidea:[email protected];
/// The commit being prepared in the window the current command or event came
/// from (the Commit tool window, or the Commit dialog while it is open): the
/// changes it holds and its message.
interface commit {
use types.{error};
/// A file the commit changes.
record commit-change {
/// Absolute path of the working tree root of its repository.
repository: string,
/// Absolute path (after a rename, the new one).
path: string,
/// The path before a rename or copy.
old-path: option<string>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or
/// `type-changed` (more may be added).
status: string,
/// What the commit changes in the file, in `git diff` format against
/// HEAD: only the changed blocks included in the commit. `none` for
/// binary content.
diff: option<string>,
/// `diff` was cut at the size limit (1 MiB for all the changes).
truncated: bool,
}
/// What Commit would commit right now.
record commit-draft {
/// Changes whenever the message, the included changes or Amend do.
revision: u64,
message: string,
/// The commit replaces the last one (Amend); `changes` are still
/// against HEAD, so they don't include the last commit's own.
amend: bool,
changes: list<commit-change>,
}
/// The commit being prepared, as Commit would make it: the included
/// files and changed blocks only. Unsaved editors of the included files
/// are saved first, as Commit does; nothing is staged or committed.
/// `none` when the window's project has no repository. Fails with
/// `invalid` outside a command or event of a window, and in a project that
/// is not trusted. Requires `vcs.read`.
pending-commit: func() -> result<option<commit-draft>, error>;
/// Replaces the commit message, only if the draft is still at `revision`
/// (else `stale`, and nothing changes): text the user typed meanwhile is
/// never overwritten. The user reviews it and commits. Same windows as
/// `pending-commit`. Requires `vcs.write`.
set-commit-message: func(revision: u64, message: string) -> result<_, error>;
}
/// Before-commit checks (`[[contributes.commitChecks]]`), run when the user
/// commits, after the IDE's own checks, for the checks enabled in Commit
/// Options. Problems stop the commit and show beside the message until the
/// user changes it or chooses Commit Anyway. Requires `vcs.read`.
interface commit-checker {
use types.{error, location};
use invocation.{invocation};
use commit.{commit-draft};
/// Something the user should look at before committing.
record commit-problem {
/// One sentence, shown after the check's title.
message: string,
/// Where it is, opened from the problem; `none` for the commit as a
/// whole (its message, for instance).
location: option<location>,
}
/// Runs the declared check `check` on `draft`: no problems lets the commit
/// go on. A call that fails or runs out of time is reported as a problem
/// of the check.
check: func(check: string, draft: commit-draft, ctx: borrow<invocation>) -> result<list<commit-problem>, error>;
}
+19
View File
@@ -0,0 +1,19 @@
package openidea:[email protected];
interface hover-provider {
use types.{position, range};
record document {
path: string,
language: string,
version: u64,
text: string,
}
record hover-content {
markdown: string,
range: option<range>,
}
hover: func(doc: document, at: position) -> option<hover-content>;
}
+21
View File
@@ -17,6 +17,7 @@ world plugin {
import editor;
import invocation;
import vcs;
import commit;
import events;
import tasks;
import diagnostics;
@@ -61,6 +62,11 @@ world plugin-semantic-tokens {
export semantic-tokens-provider;
}
world plugin-hover {
include plugin;
export hover-provider;
}
/// A plugin whose views show in the Services tool window
/// (`location = "services"`): their nodes' detail pane.
world plugin-services {
@@ -68,6 +74,12 @@ world plugin-services {
export service-provider;
}
/// A plugin with before-commit checks (`[[contributes.commitChecks]]`).
world plugin-commit-checks {
include plugin;
export commit-checker;
}
/// Only the `syntax-provider` export: guests generate it next to `plugin`
/// (the component tools merge both into one component).
world syntax {
@@ -93,7 +105,16 @@ world semantic-tokens {
export semantic-tokens-provider;
}
world hover {
export hover-provider;
}
/// Only the `service-provider` export, generated next to `plugin` like `syntax`.
world services {
export service-provider;
}
/// Only the `commit-checker` export, generated next to `plugin` like `syntax`.
world commit-checks {
export commit-checker;
}
+2
View File
@@ -487,6 +487,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_context_keys,
plugin::plugin_cancel_task,
plugin::plugin_answer_prompt,
plugin::plugin_answer_commit,
plugin::plugin_commit_checks,
plugin::plugin_view_render,
plugin::plugin_view_event,
plugin::plugin_service_detail,
+27
View File
@@ -393,6 +393,33 @@ pub fn plugin_answer_prompt(state: State<'_, AppState>, prompt_id: String, answe
state.plugins.answer_prompt(&prompt_id, answer)
}
/// A window's answer to a plugin's `commitRequested`; false when the call no
/// longer waits.
#[tauri::command]
#[specta::specta]
pub fn plugin_answer_commit(
state: State<'_, AppState>,
request_id: String,
answer: ide_host::plugins::CommitAnswer,
) -> bool {
state.plugins.answer_commit(&request_id, answer)
}
/// Runs plugins' before-commit checks on what `selection` commits, until
/// `request` is cancelled.
#[tauri::command]
#[specta::specta]
pub async fn plugin_commit_checks(
window: Window,
state: State<'_, AppState>,
selection: ide_host::plugins::CommitSelection,
checks: Vec<ide_host::plugins::PluginCommitCheckRef>,
request: ide_core::RequestId,
) -> IdeResult<Vec<ide_host::plugins::PluginCommitCheckResult>> {
let plugins = state.plugins.clone();
ide_host::plugins::commit::commit_checks(&state.host, plugins, window.label(), selection, checks, request).await
}
/// Gutter markers of plugin `line-markers` providers for an open document.
#[tauri::command]
#[specta::specta]
+79 -1
View File
@@ -1072,6 +1072,16 @@ export const commands = {
* (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed.
*/
pluginAnswerPrompt: (promptId: string, answer: string[] | null) => __TAURI_INVOKE<boolean>("plugin_answer_prompt", { promptId, answer }),
/**
* A window's answer to a plugin's `commitRequested`; false when the call no
* longer waits.
*/
pluginAnswerCommit: (requestId: string, answer: CommitAnswer) => __TAURI_INVOKE<boolean>("plugin_answer_commit", { requestId, answer }),
/**
* Runs plugins' before-commit checks on what `selection` commits, until
* `request` is cancelled.
*/
pluginCommitChecks: (selection: CommitSelection, checks: PluginCommitCheckRef[], request: RequestId) => typedError<PluginCommitCheckResult[], IdeError>(__TAURI_INVOKE("plugin_commit_checks", { selection, checks, request })),
/**
* Content of a plugin view (`doc`: the document of a custom editor; `editor`: for
* a tool window, the window's active editor, as commands get it).
@@ -2320,6 +2330,15 @@ export type CommandForm = {
export type Commit = Commit_Serialize | Commit_Deserialize;
/** A window's answer to a [`CommitRequest`]. */
export type CommitAnswer =
/** The window's project has no repository. */
{ kind: "noRepository" } | { kind: "selection"; selection: CommitSelection } |
/** The message was replaced. */
{ kind: "done" } |
/** The draft is no longer at the revision. */
{ kind: "stale" };
/** Commit Options (IDEA's commit options popup). */
export type CommitOptions = {
amend: boolean,
@@ -2340,6 +2359,41 @@ export type CommitOptions = {
exclude?: IpcPath[],
};
/** A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`). */
export type CommitProblem = {
message: string,
/** Absolute path of the file it is in, if any (IPC form). */
path: string | null,
range: Range | null,
};
/** What a plugin asks of a window's Commit view. */
export type CommitRequest =
/**
* What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
* editors of the included files are saved first.
*/
{ kind: "selection" } |
/** Replace the message if the draft is still at `revision`. */
{ kind: "setMessage"; revision: number; message: string };
/**
* What a window's Commit view would commit: per repository, the options
* `vcs_commit` would get.
*/
export type CommitSelection = {
/** Changes whenever the message, the included changes or Amend do. */
revision: number,
message: string,
amend: boolean,
repositories: CommitSelectionRepo[],
};
export type CommitSelectionRepo = {
root: IpcPath,
options: CommitOptions,
};
/** Version Control › Commit: commit checks and commit-message inspections. */
export type CommitSettings = {
/**
@@ -2361,6 +2415,8 @@ export type CommitSettings = {
bodyLimit?: number,
showRightMargin?: boolean,
wrapAtMargin?: boolean,
/** Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run. */
disabledPluginChecks?: string[],
};
export type Commit_Deserialize = {
@@ -5771,6 +5827,23 @@ export type PickItem = {
description: string | null,
};
/** A plugin's before-commit check to run (`[[contributes.commitChecks]]`). */
export type PluginCommitCheckRef = {
pluginId: string,
check: string,
};
/** What a plugin's before-commit check found. */
export type PluginCommitCheckResult = {
pluginId: string,
check: string,
/** The check's declared title. */
title: string,
problems: CommitProblem[],
/** The check could not run (the plugin failed, timed out or lacks `vcs.read`). */
error: string | null,
};
/** A plugin's context key for `when` clauses (`value` is JSON text). */
export type PluginContextKey = {
pluginId: string,
@@ -5838,7 +5911,12 @@ export type PluginEvent =
/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */
{ type: "promptClosed"; promptId: string } |
/** Bring the tool window of the plugin's view `view` to the front. */
{ type: "revealView"; pluginId: string; view: string };
{ type: "revealView"; pluginId: string; view: string } |
/**
* A command needs the window's Commit view: answer with
* `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
*/
{ type: "commitRequested"; pluginId: string; requestId: string; request: CommitRequest };
/** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */
export type PluginIcon = {
+17 -7
View File
@@ -119,6 +119,8 @@ interface MemDoc {
const EXT_LANG: Record<string, string> = {
ts: "typescript",
mts: "typescript",
cts: "typescript",
tsx: "typescriptreact",
js: "javascript",
jsx: "javascriptreact",
@@ -155,7 +157,7 @@ const MEM_LANGUAGES: LanguageInfo[] = [
{
id: "typescript",
name: "TypeScript",
extensions: ["ts"],
extensions: ["ts", "mts", "cts"],
uiMode: "TypeScript",
lineComment: "//",
blockComment: ["/*", "*/"],
@@ -377,12 +379,20 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
...(d.uri ? { uri: d.uri } : {}),
...(d.uiMode ? { uiMode: d.uiMode } : {}),
});
// An Angular project's templates (an `angular.json` above them), like the backend's `framework-ui-modes`.
const uiModeOf = (path: string) =>
path.endsWith(".html") &&
[...store.keys()].some((f) => f.endsWith("/angular.json") && path.startsWith(f.slice(0, -"angular.json".length)))
? "Angular Template"
: null;
// Framework modes mirror the backend's `framework-ui-modes` definitions.
const uiModeOf = (path: string) => {
const angular = [...store.keys()].some((f) => {
const root = f.endsWith("/angular.json")
? f.slice(0, -"angular.json".length)
: f.includes("/node_modules/@angular/core/")
? f.slice(0, f.indexOf("/node_modules/@angular/core/") + 1)
: null;
return root !== null && path.startsWith(root);
});
if (!angular) return null;
if (path.endsWith(".html")) return "Angular Template";
return /\.(?:ts|mts|cts)$/.test(path) ? "Angular TypeScript" : null;
};
const get = (doc: DocId) => {
const d = docs.get(doc);
if (!d) throw new IpcError({ kind: "notFound", message: `document ${doc} is not open` });
+33
View File
@@ -7,6 +7,8 @@
* parsed as one, and folding blocks.
*/
import { LRLanguage, LanguageSupport, foldService, matchBrackets, syntaxTree } from "@codemirror/language";
import { EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { html } from "@codemirror/lang-html";
import { javascriptLanguage } from "@codemirror/lang-javascript";
import { styleTags, tags as t } from "@lezer/highlight";
@@ -230,9 +232,40 @@ const blockFolding = foldService.of((state, lineStart, lineEnd) => {
return null;
});
const selfCloseTags = EditorView.inputHandler.of((view, from, to, text, insertTransaction) => {
const { state } = view;
if (view.composing || state.readOnly || from !== to || text !== "/") return false;
const heads = state.selection.ranges
.filter((range) => {
if (!range.empty || !angularLanguage.isActiveAt(state, range.head, -1)) return false;
const node = syntaxTree(state).resolveInner(range.head, -1);
return node.name === "TagName" && node.to === range.head && node.parent?.name === "OpenTag";
})
.map((range) => range.head);
if (!heads.length) return false;
const base = insertTransaction();
const mappedHeads = new Set(heads.map((head) => base.changes.mapPos(head, 1)));
const closing = base.state.changeByRange((range) => {
if (!range.empty || !mappedHeads.has(range.head) || base.state.sliceDoc(range.head - 1, range.head) !== "/")
return { range };
return {
range: EditorSelection.cursor(range.head + 1),
changes: {
from: range.head,
to: range.head + (base.state.sliceDoc(range.head, range.head + 1) === ">" ? 1 : 0),
insert: ">",
},
};
});
if (closing.changes.empty) return false;
view.dispatch([base, base.state.update(closing, { userEvent: "input.type", scrollIntoView: true })]);
return true;
});
export function angular(): LanguageSupport {
return new LanguageSupport(angularLanguage, [
baseHTML.support,
selfCloseTags,
blockFolding,
baseHTML.language.data.of({
closeBrackets: { brackets: ["[", "{", '"'] },
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { history, undo } from "@codemirror/commands";
import { EditorView } from "@codemirror/view";
import { ensureSyntaxTree } from "@codemirror/language";
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import { findLanguageDescription, loadSupport } from "../languages";
import { angular } from "./index";
async function inlineSupport() {
return (await loadSupport(findLanguageDescription("Angular TypeScript", "/p/app.ts")!))!;
}
const highlighter = tagHighlighter([
{ tag: t.tagName, class: "tag" },
{ tag: t.attributeName, class: "attr" },
{ tag: t.variableName, class: "var" },
{ tag: t.controlKeyword, class: "control" },
{ tag: t.definitionKeyword, class: "definition" },
{ tag: t.string, class: "string" },
]);
function tokens(state: EditorState) {
const out: string[] = [];
highlightTree(ensureSyntaxTree(state, state.doc.length, 5000)!, highlighter, (from, to, cls) =>
out.push(`${state.sliceDoc(from, to)}:${cls}`),
);
return out;
}
function type(view: EditorView, text: string) {
const { from, to } = view.state.selection.main;
const insert = () => view.state.update(view.state.replaceSelection(text), { userEvent: "input.type" });
if (!view.state.facet(EditorView.inputHandler).some((h) => h(view, from, to, text, insert))) view.dispatch(insert());
}
function editor(marked: string, extensions: Extension) {
const pos = marked.indexOf("|");
return new EditorView({
state: EditorState.create({
doc: marked.replace("|", ""),
selection: { anchor: pos },
extensions,
}),
});
}
describe("Angular inline templates", () => {
it.each(["`", "'", '"'])("highlights a %s-quoted component template", async (quote) => {
const template = '<div [title]="name">{{ name }} @if (ready) {} @for (x of xs; track x) {} @let y = count;</div>';
const body = quote === '"' ? template.replaceAll('"', "'") : template;
const state = EditorState.create({
doc: `@Component({ template: ${quote}${body}${quote} }) class App {}`,
extensions: await inlineSupport(),
});
expect(tokens(state)).toEqual(
expect.arrayContaining(["div:tag", "title:attr", "name:var", "@if:control", "@for:control", "@let:definition"]),
);
});
it.each([
"const x = { template: `<div>{{ name }}</div>` };",
"@Other({ template: `<div>{{ name }}</div>` }) class App {}",
"@Component({ nested: { template: `<div>{{ name }}</div>` } }) class App {}",
"@Component({ templateUrl: `<div>{{ name }}</div>` }) class App {}",
"Component({ template: `<div>{{ name }}</div>` });",
"@Component({ template: `<div>${name}</div>` }) class App {}",
"@Component({ template: `<div>` + suffix }) class App {}",
])("keeps unrelated strings plain: %s", async (doc) => {
expect(tokens(EditorState.create({ doc, extensions: await inlineSupport() }))).not.toContain("div:tag");
});
it("keeps ordinary TypeScript projects plain", async () => {
const support = await loadSupport(findLanguageDescription("TypeScript", "/p/app.ts")!);
expect(
tokens(EditorState.create({ doc: "@Component({ template: `<div/>` }) class App {}", extensions: support! })),
).not.toContain("div:tag");
});
it("supports quoted metadata keys and namespace decorators", async () => {
const state = EditorState.create({
doc: '@ng.Component({ "template": `<r-card/>` }) class App {}',
extensions: await inlineSupport(),
});
expect(tokens(state)).toContain("r-card:tag");
});
it.each([
"@Component(/* metadata */ { template /* key */ : /* value */ `<div/>` }) class App {}",
"@Component({ template: `<div",
"@Component({ template: '<div",
])("highlights unfinished and commented templates: %s", async (doc) => {
expect(tokens(EditorState.create({ doc, extensions: await inlineSupport() }))).toContain("div:tag");
});
it("matches a fresh parse after editing templates and decorator context", async () => {
const extensions = await inlineSupport();
let state = EditorState.create({
doc: "@Component({ template: `<div>{{ name }}</div>` }) class App {}",
extensions,
});
for (const [search, replacement] of [
["name", "user.name"],
["Component", "Other"],
["Other", "Component"],
["template:", "other:"],
]) {
const from = state.doc.toString().indexOf(search!);
state = state.update({ changes: { from, to: from + search!.length, insert: replacement } }).state;
expect(tokens(state)).toEqual(tokens(EditorState.create({ doc: state.doc, extensions })));
}
});
});
describe.each(["inline", "external"])("Angular tag closing (%s)", (mode) => {
async function support() {
return mode === "inline" ? inlineSupport() : angular();
}
function wrap(template: string) {
return mode === "inline" ? `@Component({ template: \`${template}\` }) class App {}` : template;
}
it.each([
["<div|", ">", "<div></div>"],
["<div><|", "/", "<div></div>"],
["<r-card|", "/", "<r-card/>"],
["<r-card|>", "/", "<r-card/>"],
["<input|", ">", "<input>"],
['<r-card title="a/b|"', "/", '<r-card title="a/b/"'],
["<!-- <r-card| -->", "/", "<!-- <r-card/ -->"],
["{{ count |}}", "/", "{{ count /}}"],
])("handles %s + %s", async (before, text, after) => {
const view = editor(wrap(before), await support());
try {
type(view, text);
expect(view.state.doc.toString()).toBe(wrap(after));
} finally {
view.destroy();
}
});
it("closes tags at multiple cursors in one undoable input", async () => {
const doc = wrap("<r-card\n<app-item");
const ranges = [doc.indexOf("<r-card") + 7, doc.indexOf("<app-item") + 9].map((p) => EditorSelection.cursor(p));
const view = new EditorView({
state: EditorState.create({
doc,
selection: EditorSelection.create(ranges),
extensions: [await support(), history(), EditorState.allowMultipleSelections.of(true)],
}),
});
try {
type(view, "/");
expect(view.state.doc.toString()).toBe(wrap("<r-card/>\n<app-item/>"));
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe(doc);
} finally {
view.destroy();
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { javascript } from "@codemirror/lang-javascript";
import { parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common";
import { angular, angularLanguage } from "./index";
function nextCode(node: SyntaxNode): SyntaxNode | null {
let next = node.nextSibling;
while (next && (next.name === "BlockComment" || next.name === "LineComment")) next = next.nextSibling;
return next;
}
function mixTemplate(node: SyntaxNodeRef, input: Input): NestedParse | null {
if (node.name !== "Decorator") return null;
const call = node.node.getChild("CallExpression");
const callee = call?.firstChild;
const name = callee?.name === "MemberExpression" ? callee.lastChild : callee;
if (!name || !["VariableName", "PropertyName"].includes(name.name) || input.read(name.from, name.to) !== "Component")
return null;
const args = call?.getChild("ArgList");
const metadata = args?.firstChild && nextCode(args.firstChild);
if (metadata?.name !== "ObjectExpression") return null;
for (const property of metadata.getChildren("Property")) {
const key = property.firstChild;
if (!key || !["template", "'template'", '"template"'].includes(input.read(key.from, key.to))) continue;
const colon = nextCode(key);
const value = colon?.name === ":" ? nextCode(colon) : null;
if (!value || nextCode(value) || !["String", "TemplateString"].includes(value.name)) continue;
// A dynamic JavaScript template retains its interpolation expressions.
if (value.getChild("Interpolation")) continue;
const quote = input.read(value.from, value.from + 1);
const from = value.from + 1;
const to = value.to - (!value.lastChild?.type.isError && input.read(value.to - 1, value.to) === quote ? 1 : 0);
if (from < to) return { parser: angularLanguage.parser, overlay: [{ from, to }] };
}
return null;
}
const typescript = javascript({ typescript: true });
export function angularTypescript(): LanguageSupport {
// Mount on the decorator so changing its name or metadata invalidates the template too.
const language = (typescript.language as LRLanguage).configure(
{ wrap: parseMixed(mixTemplate) },
"angular-typescript",
);
return new LanguageSupport(language, [typescript.support, angular().support]);
}
+73
View File
@@ -0,0 +1,73 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { goModParser, initialGoModState, type GoModState } from "./gomod";
import { StringStream } from "@codemirror/language";
function tokenize(line: string, state: GoModState) {
const stream = new StringStream(line, 4, 1);
const tokens: { text: string; token: string | null }[] = [];
while (!stream.eol()) {
const start = stream.pos;
const kind = goModParser.token(stream, state);
if (stream.pos > start && kind !== null) {
tokens.push({ text: stream.string.slice(start, stream.pos), token: kind });
}
}
return tokens;
}
describe("goModParser", () => {
it("highlights module declaration and go version", () => {
const state = initialGoModState();
const line1 = tokenize("module onpay-aplikasi-api", state);
expect(line1).toEqual([
{ text: "module", token: "keyword" },
{ text: "onpay-aplikasi-api", token: "string" },
]);
const line2 = tokenize("go 1.27.0", state);
expect(line2).toEqual([
{ text: "go", token: "keyword" },
{ text: "1.27.0", token: "number" },
]);
});
it("highlights require blocks with module paths and versions", () => {
const state = initialGoModState();
const line1 = tokenize("require (", state);
expect(line1).toEqual([
{ text: "require", token: "keyword" },
{ text: "(", token: "paren" },
]);
const line2 = tokenize("\tcloud.google.com/go/auth v0.20.0", state);
expect(line2).toEqual([
{ text: "cloud.google.com/go/auth", token: "string" },
{ text: "v0.20.0", token: "number" },
]);
const line3 = tokenize("\tgithub.com/shopspring/decimal v1.4.0 // indirect", state);
expect(line3).toEqual([
{ text: "github.com/shopspring/decimal", token: "string" },
{ text: "v1.4.0", token: "number" },
{ text: "// indirect", token: "comment" },
]);
const line4 = tokenize(")", state);
expect(line4).toEqual([{ text: ")", token: "paren" }]);
});
it("highlights replace directive with operator", () => {
const state = initialGoModState();
const line = tokenize("replace example.com/foo => ../bar", state);
expect(line).toEqual([
{ text: "replace", token: "keyword" },
{ text: "example.com/foo", token: "string" },
{ text: "=>", token: "operator" },
{ text: "../bar", token: "string" },
]);
});
});
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more