дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 14:15:39 +02:00
Порівняти коміти
74
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
21338d557c | ||
|
|
db5a306450 | ||
|
|
bad9429e79 | ||
|
|
c942374f8a | ||
|
|
611b27b974 | ||
|
|
71f948cc9f | ||
|
|
79a750362a | ||
|
|
f1c0d098ad | ||
|
|
d9c1a9ff22 | ||
|
|
e86011fc87 | ||
|
|
f80dd0aa49 | ||
|
|
832c397e0b | ||
|
|
0f91aac0d4 | ||
|
|
d4b2ade24d | ||
|
|
e5f642f1d1 | ||
|
|
b8a06863d9 | ||
|
|
b5d86b4532 | ||
|
|
d8814d18b0 | ||
|
|
7bd115bc97 | ||
|
|
74e66a5dae | ||
|
|
6c301a7b6b | ||
|
|
d171a946fe | ||
|
|
6ae167efc9 | ||
|
|
8432bf9997 | ||
|
|
59fba1a65d | ||
|
|
3bfd3134c0 | ||
|
|
573adbf9ab | ||
|
|
d2381096fb | ||
|
|
2c70c12bf2 | ||
|
|
3ef7fee3e2 | ||
|
|
deec6d05a0 | ||
|
|
4a98ce544b | ||
|
|
396a8d4b26 | ||
|
|
2889b301b8 | ||
|
|
e26e1d1d7d | ||
|
|
0af3bd00e7 | ||
|
|
951e81e065 | ||
|
|
023c0a6ee6 | ||
|
|
c5481e8fe4 | ||
|
|
dfacb45575 | ||
|
|
3363bb8a46 | ||
|
|
39f34ec22f | ||
|
|
34ae8acbf8 | ||
|
|
6bafcb2b5c | ||
|
|
a6ed65e6d1 | ||
|
|
009e61d27b | ||
|
|
a89c0dd229 | ||
|
|
ea644dcdb0 | ||
|
|
519c8ad8fc | ||
|
|
27fdbe71e5 | ||
|
|
d5cd5e7a55 | ||
|
|
c32a68c0b8 | ||
|
|
59cc84521f | ||
|
|
4faa37aaea | ||
|
|
aaf25d9f0d | ||
|
|
913ad6616c | ||
|
|
b80f0f9e39 | ||
|
|
460027fd4b | ||
|
|
33c5627bdb | ||
|
|
3a22a2d0f0 | ||
|
|
c169b8ab3c | ||
|
|
0dbce5d09e | ||
|
|
7fe6f5f58d | ||
|
|
abf904553d | ||
|
|
d425388f1a | ||
|
|
de123e2a21 | ||
|
|
d19dbec56a | ||
|
|
1a204b5f4b | ||
|
|
3ec7daf845 | ||
|
|
112ac05052 | ||
|
|
10b7340e2a | ||
|
|
6fa5b368e6 | ||
|
|
dd40273952 | ||
|
|
21bc81c6da |
No files matched your search
Створено
@@ -4232,6 +4232,7 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4269,6 +4270,7 @@ dependencies = [
|
||||
name = "ide-dap"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-heap",
|
||||
@@ -4287,6 +4289,7 @@ dependencies = [
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -17,11 +17,30 @@ const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else
|
||||
|
||||
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
|
||||
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Discovered, String> {
|
||||
let prefer_stored = crate::provider::prefer_stored_copilot_auth();
|
||||
match run_once(shared, spec, prefer_stored).await {
|
||||
Err(err)
|
||||
if !prefer_stored
|
||||
&& crate::provider::is_copilot_provider(&spec.id)
|
||||
&& crate::provider::has_unconfigured_copilot_ambient_token(&spec.env)
|
||||
&& super::auth_required(0, &err) =>
|
||||
{
|
||||
let retry = run_once(shared, spec, true).await;
|
||||
if retry.is_ok() {
|
||||
crate::provider::mark_prefer_stored_copilot_auth();
|
||||
}
|
||||
retry
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool) -> Result<Discovered, String> {
|
||||
let root = shared.paths.project_root.clone();
|
||||
let launch = spec.clone();
|
||||
let launch = tokio::task::spawn_blocking({
|
||||
let root = root.clone();
|
||||
move || launch.resolve_for_launch(&root)
|
||||
move || launch.resolve_for_launch_with_copilot_env(&root, strip_ambient)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -283,6 +283,11 @@ struct TaskFile {
|
||||
/// The folder (in the task store) with copies of the `before` files, for rolling back.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
saved: Option<String>,
|
||||
/// In the project's own checkout, where the user and other tasks change files too: the
|
||||
/// files that changed while one of this task's turns ran, the only ones its changes list.
|
||||
/// `None` for a worktree task, and a task saved before these were recorded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
touched: Option<BTreeSet<String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
subagents: Vec<SavedSubagent>,
|
||||
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
|
||||
@@ -293,6 +298,9 @@ struct TaskFile {
|
||||
title_renamed: bool,
|
||||
}
|
||||
|
||||
/// The changed files of a checkout, stamped.
|
||||
type Snapshot = BTreeMap<String, worktree::Stamp>;
|
||||
|
||||
struct TaskState {
|
||||
task: AgentTask,
|
||||
session_id: Option<String>,
|
||||
@@ -300,11 +308,18 @@ struct TaskState {
|
||||
wanted: Vec<AgentConfigChoice>,
|
||||
before: Arc<BTreeMap<String, worktree::Stamp>>,
|
||||
saved: Option<String>,
|
||||
/// See `TaskFile::touched`.
|
||||
touched: Option<Arc<BTreeSet<String>>>,
|
||||
pending_rollback: u32,
|
||||
/// The user named the task; see `TaskFile::title_renamed`.
|
||||
title_renamed: bool,
|
||||
/// A turn is running or about to (prompts arriving now are queued).
|
||||
busy: bool,
|
||||
/// A temporary side conversation: lives in memory only, never persisted.
|
||||
ephemeral: bool,
|
||||
/// The conversation a temporary side conversation starts with, sent to the agent with
|
||||
/// its first real message; the task's own entries hold only the questions.
|
||||
ephemeral_context: Option<String>,
|
||||
/// Background tasks the agent reported as running (`run_in_background` commands and
|
||||
/// the like), as it sent them: unlike a turn they outlive it, so an idle release
|
||||
/// waits for the last one. A (re)started process runs nothing yet.
|
||||
@@ -424,8 +439,14 @@ impl AgentManager {
|
||||
}
|
||||
|
||||
/// Routes events to `sink` (replacing the previous subscriber); returns every task.
|
||||
/// Ephemeral tasks belonged to the previous page session: a fresh one starts without them.
|
||||
pub fn subscribe(&self, sink: AgentSink) -> Vec<AgentTask> {
|
||||
*self.shared.sink.write() = Some(sink);
|
||||
let stale: Vec<Arc<Task>> = self.tasks.read().values().filter(|t| t.state.lock().ephemeral).cloned().collect();
|
||||
for task in &stale {
|
||||
self.drop_in_memory(task);
|
||||
self.shared.emit(AgentEvent::Removed { id: task.id.clone() });
|
||||
}
|
||||
let mut tasks: Vec<AgentTask> = self.tasks.read().values().map(|t| t.state.lock().task.clone()).collect();
|
||||
tasks.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
|
||||
tasks
|
||||
@@ -655,19 +676,18 @@ impl AgentManager {
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
};
|
||||
let file = TaskFile {
|
||||
let handle = self.register(TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: Some(session_id.to_owned()),
|
||||
entries: Vec::new(),
|
||||
wanted: Vec::new(),
|
||||
before: BTreeMap::new(),
|
||||
saved: None,
|
||||
touched: Some(BTreeSet::new()),
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: true,
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), handle.clone());
|
||||
});
|
||||
handle.emit_task();
|
||||
handle.persist();
|
||||
Ok(task)
|
||||
@@ -705,27 +725,8 @@ impl AgentManager {
|
||||
(root, None, base, commit, before)
|
||||
}
|
||||
};
|
||||
// Until the session reports its own, show what the agent offered last time with the choices applied.
|
||||
let catalog = self.shared.paths.catalogs.cached(&self.shared, &spec.id);
|
||||
let mut levels = catalog.thought_levels;
|
||||
let mut config = self.shared.config_for_new_task(catalog.config, &spec.id);
|
||||
for choice in &req.config {
|
||||
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(model) = model_of(&config)
|
||||
&& let Some(levels) = levels.remove(&model)
|
||||
{
|
||||
config.extend(levels.into_iter().map(|mut o| {
|
||||
if let Some(choice) =
|
||||
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
|
||||
{
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
o
|
||||
}));
|
||||
}
|
||||
// The session's settings: what the agent offered last time, with the choices applied.
|
||||
let config = self.merged_config(&spec.id, &req.config);
|
||||
let now = now_ms();
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
// Copies of the files the user had changed already, for rolling back the task's changes to them.
|
||||
@@ -766,26 +767,128 @@ impl AgentManager {
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
};
|
||||
let file = TaskFile {
|
||||
let handle = self.register(TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: None,
|
||||
entries: Vec::new(),
|
||||
wanted: req.config,
|
||||
before,
|
||||
saved,
|
||||
touched: (req.isolation == Isolation::Local).then(BTreeSet::new),
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: false,
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
});
|
||||
handle.state.lock().busy = true;
|
||||
self.tasks.write().insert(task.id.clone(), handle.clone());
|
||||
handle.emit_task();
|
||||
handle.persist();
|
||||
self.shared.runtime.spawn(async move { handle.run(prompt).await });
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// Builds the in-memory handle of a fresh task, registers it and announces it. The
|
||||
/// caller decides what else its life starts with (a persisted file, a session, a run).
|
||||
fn register(&self, file: TaskFile) -> Arc<Task> {
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(handle.id.clone(), handle.clone());
|
||||
handle.emit_task();
|
||||
handle
|
||||
}
|
||||
|
||||
/// The session's settings: what the agent offered last time, with the request's
|
||||
/// choices — and the chosen model's thought levels — applied.
|
||||
fn merged_config(&self, provider: &str, req: &[AgentConfigChoice]) -> Vec<AgentConfigOption> {
|
||||
let catalog = self.shared.paths.catalogs.cached(&self.shared, provider);
|
||||
let mut levels = catalog.thought_levels;
|
||||
let mut config = self.shared.config_for_new_task(catalog.config, provider);
|
||||
for choice in req {
|
||||
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(model) = model_of(&config)
|
||||
&& let Some(levels) = levels.remove(&model)
|
||||
{
|
||||
config.extend(levels.into_iter().map(|mut o| {
|
||||
if let Some(choice) = req.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value)) {
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
o
|
||||
}));
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// Creates a temporary side conversation of `source`: an in-memory task that is never
|
||||
/// persisted, whose session starts with its first real message (`prompt` only names
|
||||
/// the title). `source`'s conversation rides along as the context of that message.
|
||||
/// The task is read-only: its settings are pushed to a mode that asks before anything,
|
||||
/// and requests that would change the project are denied regardless. Fails for an
|
||||
/// agent that offers no way to ask.
|
||||
pub async fn create_ephemeral(&self, req: NewAgentTask, source: &str) -> IdeResult<AgentTask> {
|
||||
let spec = self
|
||||
.shared
|
||||
.spec(&req.provider)
|
||||
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
|
||||
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the topic first"))?;
|
||||
let title = title_of(&prompt);
|
||||
let mut config = self.merged_config(&spec.id, &req.config);
|
||||
config::ask_for_approvals(&mut config);
|
||||
if config::bypass_approvals(&config) {
|
||||
return Err(IdeError::invalid(
|
||||
"this agent has no mode that asks before changing things, so it can't run a side conversation",
|
||||
));
|
||||
}
|
||||
let read_only = !config::bypass_approvals(&config);
|
||||
let context = context_transcript(&self.task(source)?.state.lock().conversation.main.entries);
|
||||
let root = self.shared.paths.project_root.clone();
|
||||
let before = blocking(move || Ok(worktree::snapshot(&root).unwrap_or_default())).await?;
|
||||
let now = now_ms();
|
||||
let mut capabilities = self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default();
|
||||
capabilities.read_only = read_only;
|
||||
let task = AgentTask {
|
||||
project_root: Some(IpcPath(self.shared.paths.project_root.clone())),
|
||||
id: uuid::Uuid::new_v4().simple().to_string(),
|
||||
title,
|
||||
provider: spec.id.clone(),
|
||||
model: model_of(&config),
|
||||
status: AgentStatus::Ready,
|
||||
isolation: Isolation::Local,
|
||||
cwd: IpcPath(self.shared.paths.project_root.clone()),
|
||||
branch: None,
|
||||
base: None,
|
||||
base_commit: None,
|
||||
error: None,
|
||||
created_ms: now,
|
||||
updated_ms: now,
|
||||
commands: self.shared.known_commands(&spec.id),
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
capabilities,
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
config,
|
||||
};
|
||||
let handle = self.register(TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: None,
|
||||
entries: Vec::new(),
|
||||
wanted: req.config,
|
||||
before,
|
||||
saved: None,
|
||||
touched: None,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: false,
|
||||
});
|
||||
{
|
||||
let mut s = handle.state.lock();
|
||||
s.ephemeral = true;
|
||||
s.ephemeral_context = Some(context);
|
||||
}
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// Sends a message. While the agent works it is queued, or with `now` the current turn
|
||||
/// is cancelled and the message goes next.
|
||||
pub async fn prompt(&self, id: &str, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
|
||||
@@ -1006,6 +1109,7 @@ impl AgentManager {
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
saved: s.saved.clone(),
|
||||
touched: s.touched.as_deref().cloned(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: total - keep,
|
||||
title_renamed: s.title_renamed,
|
||||
@@ -1205,6 +1309,15 @@ impl AgentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a task from the manager's table and stops its agent. The disk (its file,
|
||||
/// its worktree) is the caller's business.
|
||||
fn drop_in_memory(&self, task: &Arc<Task>) {
|
||||
task.kill();
|
||||
self.tasks.write().remove(&task.id);
|
||||
*task.deleted.lock() = true;
|
||||
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{}.json", task.id)));
|
||||
}
|
||||
|
||||
/// Returns an archived task to the active list without starting the agent.
|
||||
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
@@ -1288,8 +1401,8 @@ impl AgentManager {
|
||||
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let task = self.task(id)?;
|
||||
let at = task.base();
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || at.changes(&before)).await
|
||||
let (before, touched) = task.attribution();
|
||||
blocking(move || Ok(worktree::made_by_task(at.changes(&before)?, touched.as_deref()))).await
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and, for a worktree task, the commits made since it
|
||||
@@ -1297,8 +1410,13 @@ impl AgentManager {
|
||||
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
|
||||
let task = self.task(id)?;
|
||||
let at = task.base();
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || at.grouped_changes(&before)).await
|
||||
let (before, touched) = task.attribution();
|
||||
blocking(move || {
|
||||
let mut grouped = at.grouped_changes(&before)?;
|
||||
grouped.uncommitted = worktree::made_by_task(grouped.uncommitted, touched.as_deref());
|
||||
Ok(grouped)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Content of `path` (in the task's directory) when the task started.
|
||||
@@ -1324,15 +1442,16 @@ impl AgentManager {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
}
|
||||
let at = task.base();
|
||||
let (before, saved) = {
|
||||
let (before, saved, touched) = {
|
||||
let s = task.state.lock();
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)), s.touched.clone())
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
let (changes, rev) = match view {
|
||||
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
|
||||
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
|
||||
};
|
||||
let changes = worktree::made_by_task(changes, touched.as_deref());
|
||||
let sources = match rev.as_deref() {
|
||||
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
|
||||
None => Default::default(),
|
||||
@@ -1423,9 +1542,12 @@ impl Task {
|
||||
wanted: file.wanted,
|
||||
before: Arc::new(file.before),
|
||||
saved: file.saved,
|
||||
touched: file.touched.map(Arc::new),
|
||||
pending_rollback: file.pending_rollback,
|
||||
title_renamed: file.title_renamed,
|
||||
busy: false,
|
||||
ephemeral: false,
|
||||
ephemeral_context: None,
|
||||
background_tasks: Vec::new(),
|
||||
}),
|
||||
live: Mutex::new(None),
|
||||
@@ -1447,6 +1569,41 @@ impl Task {
|
||||
})
|
||||
}
|
||||
|
||||
/// The files already changed when the task started, and the files its turns changed.
|
||||
fn attribution(&self) -> (Arc<Snapshot>, Option<Arc<BTreeSet<String>>>) {
|
||||
let s = self.state.lock();
|
||||
(s.before.clone(), s.touched.clone())
|
||||
}
|
||||
|
||||
/// The changed files of the checkout as a turn starts, when the task records what its turns change.
|
||||
async fn checkout_before_turn(&self) -> Option<Snapshot> {
|
||||
let cwd = {
|
||||
let s = self.state.lock();
|
||||
s.touched.as_ref()?;
|
||||
s.task.cwd.0.clone()
|
||||
};
|
||||
blocking(move || worktree::snapshot(&cwd)).await.ok()
|
||||
}
|
||||
|
||||
/// Records the files that changed in the checkout since `start`, as the turn ends, as the task's.
|
||||
async fn record_turn_changes(&self, start: Option<Snapshot>) {
|
||||
let Some(start) = start else { return };
|
||||
let cwd = self.state.lock().task.cwd.0.clone();
|
||||
let Ok(end) = blocking(move || worktree::snapshot(&cwd)).await else { return };
|
||||
let changed = worktree::changed_between(&start, &end);
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
let Some(touched) = &s.touched else { return };
|
||||
let mut touched = (**touched).clone();
|
||||
touched.extend(changed);
|
||||
s.touched = Some(Arc::new(touched));
|
||||
}
|
||||
self.persist();
|
||||
}
|
||||
|
||||
fn base(&self) -> worktree::TaskBase {
|
||||
let s = self.state.lock();
|
||||
let cwd = s.task.cwd.0.clone();
|
||||
@@ -1527,6 +1684,9 @@ impl Task {
|
||||
if *deleted {
|
||||
return;
|
||||
}
|
||||
if self.state.lock().ephemeral {
|
||||
return;
|
||||
}
|
||||
let bytes = {
|
||||
let s = self.state.lock();
|
||||
serde_json::to_vec(&TaskFile {
|
||||
@@ -1536,6 +1696,7 @@ impl Task {
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
saved: s.saved.clone(),
|
||||
touched: s.touched.as_deref().cloned(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: s.pending_rollback,
|
||||
title_renamed: s.title_renamed,
|
||||
@@ -1587,9 +1748,11 @@ impl Task {
|
||||
/// the user stops one. The caller set `busy`.
|
||||
async fn run(self: Arc<Self>, mut prompt: AgentPrompt) {
|
||||
loop {
|
||||
let start = self.checkout_before_turn().await;
|
||||
let result = self.turn(&prompt).await;
|
||||
self.cancel_pending.store(false, Ordering::Release);
|
||||
tokio::time::sleep(TURN_GRACE).await;
|
||||
self.record_turn_changes(start).await;
|
||||
let changed = self.state.lock().conversation.end_turn(Some(now_ms()));
|
||||
self.mark_dirty(changed);
|
||||
let send_now = self.send_now.swap(false, Ordering::AcqRel);
|
||||
@@ -1757,7 +1920,20 @@ impl Task {
|
||||
if !prompt.images.is_empty() && !live.capabilities.images {
|
||||
return Err(Failure::from("This agent can't read images.".to_owned()));
|
||||
}
|
||||
let content = self.content(&live, prompt).await;
|
||||
// A temporary side conversation's context rides with its first real message; the
|
||||
// conversation itself records only the question (and whatever comes after).
|
||||
let prompt = {
|
||||
let context = self.state.lock().ephemeral_context.take();
|
||||
match context {
|
||||
Some(context) => {
|
||||
let mut owned = prompt.clone();
|
||||
owned.text = format!("{context}\n\n---\n\n{}", owned.text);
|
||||
owned
|
||||
}
|
||||
None => prompt.clone(),
|
||||
}
|
||||
};
|
||||
let content = self.content(&live, &prompt).await;
|
||||
let result: Result<Value, RpcError> =
|
||||
live.peer.request("session/prompt", json!({ "sessionId": live.session, "prompt": content }), None).await;
|
||||
match result {
|
||||
@@ -2211,6 +2387,29 @@ impl Task {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// A side conversation is read-only: deny anything that would change the project
|
||||
// (its task is deleted when its pane closes, leaving nothing to review or revert).
|
||||
if self.state.lock().ephemeral
|
||||
&& params
|
||||
.pointer("/toolCall/kind")
|
||||
.or_else(|| params.pointer("/subject/toolCall/kind"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "edit" | "delete" | "move" | "execute"))
|
||||
{
|
||||
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
|
||||
let _ = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
command: None,
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
chosen: Some("Denied: side conversations are read-only".to_owned()),
|
||||
});
|
||||
return match reject {
|
||||
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
|
||||
None => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
};
|
||||
}
|
||||
let asked: Vec<String> = questions
|
||||
.iter()
|
||||
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
|
||||
@@ -2408,6 +2607,72 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
|
||||
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
|
||||
}
|
||||
|
||||
/// The character budget of a side conversation's context: the newest entries win, the
|
||||
/// oldest are dropped first. Leaves room for the question and the agent's answer.
|
||||
const CONTEXT_BUDGET: usize = 100_000;
|
||||
|
||||
/// A long tool output trimmed to its first and last few lines, with the elided middle noted.
|
||||
fn trimmed_output(output: &str) -> String {
|
||||
const KEPT: usize = 4;
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
if lines.len() <= KEPT * 2 + 1 {
|
||||
return output.to_owned();
|
||||
}
|
||||
let omitted = lines.len() - KEPT * 2;
|
||||
let mut out = lines[..KEPT].join("\n");
|
||||
out.push_str(&format!("\n… {omitted} lines omitted …\n"));
|
||||
out.push_str(&lines[lines.len() - KEPT..].join("\n"));
|
||||
out
|
||||
}
|
||||
|
||||
fn context_entry(e: &AgentEntry) -> Option<String> {
|
||||
match e {
|
||||
AgentEntry::User { text, .. } => Some(format!("User: {text}")),
|
||||
AgentEntry::Agent { text, .. } if !text.trim().is_empty() => Some(format!("Agent: {text}")),
|
||||
AgentEntry::Thought { text, .. } if !text.trim().is_empty() => Some(format!("Thought: {text}")),
|
||||
AgentEntry::Tool { title, output, .. } => match output.as_deref() {
|
||||
Some(output) if !output.trim().is_empty() => Some(format!("Tool: {title}\n{}", trimmed_output(output))),
|
||||
_ => Some(format!("Tool: {title}")),
|
||||
},
|
||||
AgentEntry::Error { message, .. } => Some(format!("Error: {message}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversation as the context a temporary side conversation starts with: what was
|
||||
/// said, what was done (tool calls with trimmed output), errors and the agent's reasoning,
|
||||
/// oldest first, within [`CONTEXT_BUDGET`].
|
||||
fn context_transcript(entries: &[AgentEntry]) -> String {
|
||||
// Newest wins: walk back until the budget is spent, dropping the oldest entries first.
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut used = 0usize;
|
||||
for e in entries.iter().rev() {
|
||||
let Some(line) = context_entry(e) else { continue };
|
||||
if used + line.len() > CONTEXT_BUDGET {
|
||||
if lines.is_empty() {
|
||||
// Even the newest entry alone exceeds the budget: keep its head.
|
||||
lines.push(line.chars().take(CONTEXT_BUDGET).collect());
|
||||
}
|
||||
break;
|
||||
}
|
||||
used += line.len();
|
||||
lines.push(line);
|
||||
}
|
||||
lines.reverse();
|
||||
let dropped = entries.iter().rev().filter_map(context_entry).count() - lines.len();
|
||||
let mut context = String::from(
|
||||
"You are answering a side question in a temporary conversation. The conversation so far is below, \
|
||||
for context only; answer the new question after the marker, and do not continue the main task \
|
||||
unless asked.\n\n<conversation>\n",
|
||||
);
|
||||
if dropped > 0 {
|
||||
context.push_str(&format!("(The {dropped} oldest entries are omitted.)\n\n"));
|
||||
}
|
||||
context.push_str(&lines.join("\n\n"));
|
||||
context.push_str("\n</conversation>");
|
||||
context
|
||||
}
|
||||
|
||||
fn permission_title(params: &Value) -> String {
|
||||
let call = ¶ms["toolCall"];
|
||||
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
|
||||
@@ -2608,6 +2873,7 @@ fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
|
||||
rollback: meta("/openidea/rollback"),
|
||||
steer: meta("/openidea/steer"),
|
||||
background_tasks: meta("/openidea/backgroundTasks"),
|
||||
read_only: false,
|
||||
};
|
||||
(capabilities, init.prompt("embeddedContext"))
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ pub struct AgentCapabilities {
|
||||
pub steer: bool,
|
||||
/// It reports its background tasks and stops them one by one.
|
||||
pub background_tasks: bool,
|
||||
/// The task runs read-only: it asks before anything, and requests that would change
|
||||
/// the project are denied (a temporary side conversation).
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -120,10 +120,18 @@ impl ProviderSpec {
|
||||
}
|
||||
|
||||
/// Resolve once before connecting, so authentication guidance names that same install.
|
||||
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
|
||||
pub(crate) fn resolve_for_launch(self, cwd: &Path) -> Self {
|
||||
let strip = prefer_stored_copilot_auth();
|
||||
self.resolve_for_launch_with_copilot_env(cwd, strip)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_for_launch_with_copilot_env(mut self, cwd: &Path, strip_ambient_tokens: bool) -> Self {
|
||||
if self.driver != Driver::Acp {
|
||||
self.command = crate::native::newest_install(&self.command);
|
||||
}
|
||||
if is_copilot_provider(&self.id) {
|
||||
self.env = sanitized_copilot_env(&self.env, strip_ambient_tokens);
|
||||
}
|
||||
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
|
||||
self.command =
|
||||
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
|
||||
@@ -133,6 +141,69 @@ impl ProviderSpec {
|
||||
}
|
||||
}
|
||||
|
||||
const AMBIENT_COPILOT_AUTH_VARS: [&str; 3] = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
||||
static PREFER_STORED_COPILOT_AUTH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn is_copilot_provider(id: &str) -> bool {
|
||||
id == "copilot" || id.starts_with("copilot-")
|
||||
}
|
||||
|
||||
pub(crate) fn prefer_stored_copilot_auth() -> bool {
|
||||
PREFER_STORED_COPILOT_AUTH.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_prefer_stored_copilot_auth() {
|
||||
PREFER_STORED_COPILOT_AUTH.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) fn has_unconfigured_copilot_ambient_token(spec_env: &[(String, String)]) -> bool {
|
||||
AMBIENT_COPILOT_AUTH_VARS.iter().any(|&key| {
|
||||
!spec_env.iter().any(|(k, v)| k == key && !v.is_empty())
|
||||
&& ambient_copilot_env_value(key).is_some_and(|v| !v.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn ambient_copilot_env_value(key: &str) -> Option<String> {
|
||||
ide_core::process::shell_env()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v.to_string_lossy().into_owned())
|
||||
.or_else(|| ide_core::process::host_var_os(key).map(|v| v.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Builds child environment overrides for `copilot`:
|
||||
/// - Always clears ambient classic PATs (`ghp_…`), which Copilot CLI unconditionally rejects
|
||||
/// (`"Classic PATs are not supported"`) instead of falling back to Keychain / `gh` login.
|
||||
/// - When `strip_ambient_tokens` is true, clears unconfigured ambient `GITHUB_TOKEN` / `GH_TOKEN` /
|
||||
/// `COPILOT_GITHUB_TOKEN` so Copilot uses its stored login (`~/.copilot` or `gh auth` keyring),
|
||||
/// while preserving any token explicitly configured in OpenIDEA's agent `spec_env`.
|
||||
pub(crate) fn sanitized_copilot_env(
|
||||
spec_env: &[(String, String)],
|
||||
strip_ambient_tokens: bool,
|
||||
) -> Vec<(String, String)> {
|
||||
sanitize_copilot_env_with(spec_env, strip_ambient_tokens, ambient_copilot_env_value)
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_copilot_env_with(
|
||||
spec_env: &[(String, String)],
|
||||
strip_ambient_tokens: bool,
|
||||
lookup: impl Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut out = Vec::with_capacity(spec_env.len() + AMBIENT_COPILOT_AUTH_VARS.len());
|
||||
for key in AMBIENT_COPILOT_AUTH_VARS {
|
||||
let explicit = spec_env.iter().any(|(k, v)| k == key && !v.is_empty());
|
||||
if explicit {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = lookup(key).filter(|v| !v.is_empty())
|
||||
&& (strip_ambient_tokens || val.trim().starts_with("ghp_"))
|
||||
{
|
||||
out.push((key.to_owned(), String::new()));
|
||||
}
|
||||
}
|
||||
out.extend(spec_env.iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
|
||||
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
|
||||
let prefix = format!("Run `{}", builtin.command);
|
||||
@@ -1101,4 +1172,23 @@ mod tests {
|
||||
import_acp(dir.path(), None, "not json").unwrap_err();
|
||||
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_classic_pats_and_ambient_tokens_while_preserving_explicit_agent_env() {
|
||||
let lookup = |k: &str| match k {
|
||||
"GITHUB_TOKEN" => Some("ghp_classic_pat_123".into()),
|
||||
"GH_TOKEN" => Some("gho_oauth_token_456".into()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(sanitize_copilot_env_with(&[], false, lookup), vec![("GITHUB_TOKEN".into(), String::new())]);
|
||||
assert_eq!(
|
||||
sanitize_copilot_env_with(&[], true, lookup),
|
||||
vec![("GH_TOKEN".into(), String::new()), ("GITHUB_TOKEN".into(), String::new())]
|
||||
);
|
||||
let explicit = vec![("GH_TOKEN".into(), "github_pat_custom".into())];
|
||||
assert_eq!(
|
||||
sanitize_copilot_env_with(&explicit, true, lookup),
|
||||
vec![("GITHUB_TOKEN".into(), String::new()), ("GH_TOKEN".into(), "github_pat_custom".into())]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2211,6 +2211,7 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
.unwrap();
|
||||
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
count_every_change(&mgr, &task.id);
|
||||
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
|
||||
assert_eq!(task.base.as_deref(), Some("member-main"));
|
||||
if isolation == Isolation::Local {
|
||||
@@ -2842,12 +2843,14 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// What the agent did.
|
||||
// What the agent did, while its turn ran.
|
||||
write("mine.txt", "my edit\nand the agent's\n");
|
||||
write("other.txt", "agent\n");
|
||||
write("new.txt", "agent\n");
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// The user's (or another task's) work between turns is not the task's.
|
||||
write("later.txt", "mine\n");
|
||||
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
|
||||
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
|
||||
}
|
||||
@@ -2863,6 +2866,7 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
|
||||
assert!(!dir.join("new.txt").exists());
|
||||
assert_eq!(read("later.txt"), "mine\n", "not the task's to roll back");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
@@ -2947,10 +2951,17 @@ impl Checkout {
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
count_every_change(&mgr, &task.id);
|
||||
(mgr, task)
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts every change in the checkout as the task's, as for a task saved before tasks
|
||||
/// recorded what their turns changed: for tests that change files outside a turn.
|
||||
fn count_every_change(mgr: &AgentManager, id: &str) {
|
||||
mgr.task(id).unwrap().state.lock().touched = None;
|
||||
}
|
||||
|
||||
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
|
||||
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
|
||||
}
|
||||
@@ -3227,3 +3238,192 @@ async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_co
|
||||
"{entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A temporary side conversation is created ready, without starting its session and
|
||||
/// without writing anything to the store; a fresh subscriber (a reloaded page) sweeps
|
||||
/// it, and deleting it just drops it from memory.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ephemeral_tasks_stay_in_memory_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let source = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("hello"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
|
||||
let calls_before = agent.calls.lock().len();
|
||||
let task = mgr
|
||||
.create_ephemeral(
|
||||
NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("What changed in this file?"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
},
|
||||
&source.id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Registering it starts nothing: the session waits for its first real message.
|
||||
assert_eq!(agent.calls.lock().len(), calls_before);
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
|
||||
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
|
||||
|
||||
// Its first message starts the session like any task's, and still saves nothing: the
|
||||
// conversation records the question, not the context that rode along to the agent.
|
||||
// The fake agent's turn asks to change something, which a side conversation denies by
|
||||
// itself, so the turn ends without waiting for the user.
|
||||
mgr.prompt(&task.id, text("Why?"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let question = entries.iter().find_map(|e| match e {
|
||||
AgentEntry::User { text, .. } => Some(text.clone()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(question.as_deref(), Some("Why?"));
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c.contains("read-only"))),
|
||||
"its `cargo test` request was denied: {entries:?}"
|
||||
);
|
||||
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
|
||||
|
||||
// A fresh subscriber (a reloaded page) sweeps it: it never reaches the task list.
|
||||
let events: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_events = events.clone();
|
||||
let tasks = mgr.subscribe(Arc::new(move |e| sink_events.lock().push(e)));
|
||||
assert!(!tasks.iter().any(|t| t.id == task.id));
|
||||
assert!(mgr.task(&task.id).is_err());
|
||||
assert!(events.lock().iter().any(|e| matches!(e, AgentEvent::Removed { id } if *id == task.id)));
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
/// A side conversation's context stays within its budget: long tool outputs are cut to
|
||||
/// their head and tail, and entries that no longer fit are dropped oldest first.
|
||||
#[test]
|
||||
fn side_conversation_context_is_bounded() {
|
||||
let long_output = (0..400).map(|i| format!("line {i}")).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
);
|
||||
// Each reply alone is well inside the budget; together they are past it.
|
||||
let reply = |n: usize| AgentEntry::Agent { text: format!("reply {n} {}", "x".repeat(30_000)) };
|
||||
let entries = vec![
|
||||
AgentEntry::User {
|
||||
text: "oldest question".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: None,
|
||||
done_ms: None,
|
||||
steered: false,
|
||||
},
|
||||
reply(1),
|
||||
reply(2),
|
||||
reply(3),
|
||||
reply(4),
|
||||
AgentEntry::Tool {
|
||||
id: "t1".into(),
|
||||
title: "grep".into(),
|
||||
kind: "search".into(),
|
||||
status: AgentToolStatus::Completed,
|
||||
locations: vec![],
|
||||
output: Some(long_output),
|
||||
diffs: vec![],
|
||||
},
|
||||
AgentEntry::User {
|
||||
text: "newest question".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: None,
|
||||
done_ms: None,
|
||||
steered: false,
|
||||
},
|
||||
];
|
||||
let transcript = context_transcript(&entries);
|
||||
assert!(transcript.contains("newest question"), "the newest entry is always kept");
|
||||
assert!(transcript.contains("lines omitted"), "a long tool output is trimmed");
|
||||
assert!(transcript.contains("line 0") && transcript.contains("line 399"), "its head and tail stay");
|
||||
assert!(!transcript.contains("oldest question"), "entries past the budget are dropped oldest first");
|
||||
assert!(transcript.contains("oldest entries are omitted"));
|
||||
}
|
||||
|
||||
/// An ephemeral task denies a permission request that would change the project.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ephemeral_tasks_deny_write_permissions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let source = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Something with a conversation to reuse"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
|
||||
let pending = mgr
|
||||
.entries(&source.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no pending permission request"));
|
||||
mgr.respond(&source.id, pending, Some("no".into())).unwrap();
|
||||
wait_for(&mgr, &source.id, AgentStatus::Ready).await;
|
||||
|
||||
let task = mgr
|
||||
.create_ephemeral(
|
||||
NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("What changed in this file?"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
},
|
||||
&source.id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.read_only);
|
||||
|
||||
let answer = mgr
|
||||
.task(&task.id)
|
||||
.unwrap()
|
||||
.ask_permission(&json!({
|
||||
"sessionId": "c1",
|
||||
"toolCall": {"toolCallId": "c1-t2", "title": "Edit main.rs", "kind": "edit"},
|
||||
"options": [
|
||||
{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}
|
||||
]
|
||||
}))
|
||||
.pointer("/outcome/optionId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("none")
|
||||
.to_owned();
|
||||
assert_eq!(answer, "no", "the write request is denied");
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(chosen), .. } if chosen.contains("read-only"))),
|
||||
"{entries:?}"
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::model::{AgentChange, AgentChanges, AgentCommit};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, LogQuery, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
@@ -548,6 +548,25 @@ pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
|
||||
Ok(repo.status()?.into_iter().map(|s| (s.rel_path, stamp(&s.path))).collect())
|
||||
}
|
||||
|
||||
/// The files whose state changed between two snapshots of a checkout: edited, new, deleted,
|
||||
/// or back to as committed.
|
||||
pub(crate) fn changed_between(start: &BTreeMap<String, Stamp>, end: &BTreeMap<String, Stamp>) -> Vec<String> {
|
||||
let mut changed: Vec<String> =
|
||||
end.iter().filter(|(path, stamp)| start.get(*path) != Some(*stamp)).map(|(path, _)| path.clone()).collect();
|
||||
changed.extend(start.keys().filter(|path| !end.contains_key(*path)).cloned());
|
||||
changed
|
||||
}
|
||||
|
||||
/// The changes among `changes` that the task made, when it records the files its turns
|
||||
/// changed (`None`: every change counts).
|
||||
pub(crate) fn made_by_task(changes: Vec<AgentChange>, touched: Option<&BTreeSet<String>>) -> Vec<AgentChange> {
|
||||
let Some(touched) = touched else { return changes };
|
||||
changes
|
||||
.into_iter()
|
||||
.filter(|c| touched.contains(&c.rel_path) || c.old_path.as_ref().is_some_and(|old| touched.contains(old)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drops the changes that were there before the task and haven't been touched since.
|
||||
pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<String, Stamp>) -> Vec<AgentChange> {
|
||||
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
|
||||
@@ -1184,4 +1203,20 @@ mod tests {
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_changed_the_files_whose_state_moved() {
|
||||
let start: BTreeMap<String, Stamp> =
|
||||
[("edited", Some((1, 1))), ("same", Some((2, 2))), ("reverted", Some((3, 3)))]
|
||||
.into_iter()
|
||||
.map(|(p, s)| (p.to_owned(), s))
|
||||
.collect();
|
||||
let end: BTreeMap<String, Stamp> = [("edited", Some((1, 9))), ("same", Some((2, 2))), ("new", Some((4, 4)))]
|
||||
.into_iter()
|
||||
.map(|(p, s)| (p.to_owned(), s))
|
||||
.collect();
|
||||
let mut changed = changed_between(&start, &end);
|
||||
changed.sort();
|
||||
assert_eq!(changed, ["edited", "new", "reverted"]);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ pub enum BuildSystem {
|
||||
TypeScript,
|
||||
#[serde(rename = "cmake")]
|
||||
CMake,
|
||||
#[serde(rename = "dotnet")]
|
||||
Dotnet,
|
||||
}
|
||||
|
||||
/// One command of a build.
|
||||
@@ -165,6 +167,8 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
|
||||
{
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if dotnet_target(dir).is_some() {
|
||||
Some(BuildSystem::Dotnet)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
} else if has(dir, &["go.mod", "go.work"]) {
|
||||
@@ -225,7 +229,7 @@ pub fn plan(
|
||||
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
|
||||
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, .NET, Cargo, Go, TypeScript, CMake) found")
|
||||
})?;
|
||||
let module = module_dir.as_deref().filter(|d| *d != root);
|
||||
let title = match module {
|
||||
@@ -244,6 +248,7 @@ pub fn plan(
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
|
||||
BuildSystem::TypeScript => ts_steps(&dir, kind),
|
||||
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
|
||||
BuildSystem::Dotnet => dotnet_steps(&dir, kind)?,
|
||||
};
|
||||
Ok(BuildPlan { system, title, steps })
|
||||
}
|
||||
@@ -451,6 +456,33 @@ fn has_build_script(dir: &Path) -> bool {
|
||||
.is_some_and(|v| v.pointer("/scripts/build").is_some_and(serde_json::Value::is_string))
|
||||
}
|
||||
|
||||
/// The solution (`.sln` / `.slnx`) or project (`.csproj` / `.fsproj` / `.vbproj`) file `dir`
|
||||
/// builds: a solution first, then a project; the first by name when there are several.
|
||||
fn dotnet_target(dir: &Path) -> Option<PathBuf> {
|
||||
let mut files: Vec<PathBuf> =
|
||||
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
|
||||
files.sort();
|
||||
let ext = |p: &Path| p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
let solution = files.iter().find(|p| matches!(ext(p).as_str(), "sln" | "slnx"));
|
||||
solution.or_else(|| files.iter().find(|p| matches!(ext(p).as_str(), "csproj" | "fsproj" | "vbproj"))).cloned()
|
||||
}
|
||||
|
||||
/// `dotnet build` of the directory's solution or project; Rebuild skips incremental builds,
|
||||
/// Build Artifacts publishes. The summary that repeats every message is left out.
|
||||
fn dotnet_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
|
||||
let target = dotnet_target(dir).ok_or_else(|| IdeError::not_found("no .NET solution or project found"))?;
|
||||
let target = target.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
|
||||
let verb = if kind == BuildKind::Artifacts { "publish" } else { "build" };
|
||||
let mut step = BuildStep::new("dotnet", &[verb, &target, "-nologo", "-clp:NoSummary"], dir, Format::Msbuild);
|
||||
if kind == BuildKind::Rebuild {
|
||||
step.args.push("--no-incremental".into());
|
||||
}
|
||||
// The classic console logger prints the canonical `file(line,col): error` lines; the
|
||||
// terminal logger (.NET 8+) only appears on a terminal, but rules it out for older SDKs too.
|
||||
step.env.insert("MSBUILDTERMINALLOGGER".into(), "off".into());
|
||||
Ok(vec![step])
|
||||
}
|
||||
|
||||
fn ts_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
let script = has_build_script(dir);
|
||||
if script && (kind == BuildKind::Artifacts || !dir.join("tsconfig.json").is_file()) {
|
||||
@@ -1141,6 +1173,24 @@ mod tests {
|
||||
("pnpm", &["run".to_owned(), "build".to_owned()][..])
|
||||
);
|
||||
|
||||
// .NET: the solution, else the project; a module build is the file's project. An ASP.NET
|
||||
// project's package.json (its front end) doesn't make it a TypeScript build.
|
||||
let net = tmp.path().join("net");
|
||||
write(&net, "App.sln", "");
|
||||
write(&net, "src/Api/Api.csproj", "<Project/>");
|
||||
write(&net, "src/Api/package.json", "{}");
|
||||
write(&net, "src/Api/Program.cs", "");
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Dotnet);
|
||||
assert_eq!(p.steps[0].args, ["build", "App.sln", "-nologo", "-clp:NoSummary"]);
|
||||
assert_eq!(p.steps[0].env["MSBUILDTERMINALLOGGER"], "off");
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Rebuild, Some(&net.join("src/Api/Program.cs")), None)
|
||||
.unwrap();
|
||||
assert_eq!(p.steps[0].args, ["build", "Api.csproj", "-nologo", "-clp:NoSummary", "--no-incremental"]);
|
||||
assert_eq!(p.steps[0].cwd, net.join("src/Api"));
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
|
||||
assert_eq!(p.steps[0].args[0], "publish");
|
||||
|
||||
let cm = tmp.path().join("cm");
|
||||
write(&cm, "CMakeLists.txt", "project(app)");
|
||||
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
|
||||
|
||||
@@ -25,6 +25,8 @@ pub(crate) enum Format {
|
||||
Go,
|
||||
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
|
||||
Gcc,
|
||||
/// MSBuild (`dotnet build`): `C:\p\A.cs(12,5): error CS1002: msg [C:\p\App.csproj]`.
|
||||
Msbuild,
|
||||
}
|
||||
|
||||
/// What a printed column counts.
|
||||
@@ -268,6 +270,43 @@ fn tsc_line(line: &str) -> Option<RawMessage> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// MSBuild's canonical form: `path(12,5): error CS1002: msg [project]`, `path(12,5,12,9): …`,
|
||||
/// or `path : error NU1101: msg [project]` for a project-level message (no position).
|
||||
/// The path may contain spaces (`C:\Users\Jane Doe\…`); "error" / "warning" are never localized.
|
||||
fn msbuild_line(line: &str) -> Option<RawMessage> {
|
||||
let line = line.trim();
|
||||
// The project the message came from, appended by the console logger.
|
||||
let line = match line.rfind(" [") {
|
||||
Some(i) if line.ends_with(']') => &line[..i],
|
||||
_ => line,
|
||||
};
|
||||
let (head, severity, rest) =
|
||||
[(" error ", Severity::Error), (" warning ", Severity::Warning)].into_iter().find_map(|(word, severity)| {
|
||||
let i = line.find(&format!(":{word}"))?;
|
||||
Some((&line[..i], severity, &line[i + word.len() + 1..]))
|
||||
})?;
|
||||
let (code, msg) = rest.split_once(':')?;
|
||||
let code = code.trim();
|
||||
if code.is_empty() || code.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
let head = head.trim_end();
|
||||
let (path, pos) = match head.strip_suffix(')').and_then(|h| h.rsplit_once('(')) {
|
||||
Some((path, pos)) => (path, pos.split(',').map(num).collect::<Vec<_>>()),
|
||||
None => (head, Vec::new()),
|
||||
};
|
||||
let at = |i: usize| pos.get(i).copied().flatten();
|
||||
// `MSBUILD : error MSB1009` and the like name no file.
|
||||
let file = path.rsplit(['/', '\\']).next().is_some_and(|n| n.contains('.'));
|
||||
let mut m = RawMessage::new(severity, msg.trim(), ColUnit::Utf16);
|
||||
if file {
|
||||
m.path = Some(path.trim().to_owned());
|
||||
(m.line, m.col, m.end_line, m.end_col) = (at(0), at(1), at(2), at(3));
|
||||
}
|
||||
m.code = Some(code.to_owned());
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// go: `./a.go:12:5: msg` (errors only; `go vet` style `a.go:12: msg` too).
|
||||
fn go_line(line: &str) -> Option<RawMessage> {
|
||||
let (path, ln, col, rest) = path_line_col(line)?;
|
||||
@@ -404,6 +443,7 @@ impl OutputParser {
|
||||
Format::Tsc => tsc_line(&line),
|
||||
Format::Go => go_line(&line),
|
||||
Format::Gcc => gcc_line(&line),
|
||||
Format::Msbuild => msbuild_line(&line),
|
||||
Format::Cargo => None,
|
||||
};
|
||||
if let Some(msg) = parsed {
|
||||
@@ -551,6 +591,27 @@ mod tests {
|
||||
assert!(plain.messages.is_empty() && plain.console.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msbuild_canonical_messages() {
|
||||
let out = parse(
|
||||
Format::Msbuild,
|
||||
" Determining projects to restore...\n\
|
||||
C:\\Users\\Jane Doe\\App\\Program.cs(12,5): error CS1002: ; expected [C:\\Users\\Jane Doe\\App\\App.csproj]\n\
|
||||
/p/App/Models/User.cs(3,8,3,12): warning CS0168: The variable 'e' is declared but never used [/p/App/App.csproj]\n\
|
||||
/p/App/App.csproj : error NU1101: Unable to find package Foo. [/p/App/App.csproj]\n\
|
||||
MSBUILD : error MSB1009: Project file does not exist.\n\
|
||||
Build FAILED.\n",
|
||||
);
|
||||
assert_eq!(out.len(), 4, "{out:?}");
|
||||
assert_eq!(pos(&out[0]), (Severity::Error, "C:\\Users\\Jane Doe\\App\\Program.cs", 12, Some(5)));
|
||||
assert_eq!((out[0].code.as_deref(), out[0].text.as_str()), (Some("CS1002"), "; expected"));
|
||||
assert_eq!(pos(&out[1]), (Severity::Warning, "/p/App/Models/User.cs", 3, Some(8)));
|
||||
assert_eq!((out[1].end_line, out[1].end_col), (Some(3), Some(12)));
|
||||
assert_eq!((out[2].path.as_deref(), out[2].line), (Some("/p/App/App.csproj"), None));
|
||||
assert_eq!(out[2].text, "Unable to find package Foo.");
|
||||
assert_eq!((out[3].path.as_deref(), out[3].code.as_deref()), (None, Some("MSB1009")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsc_plain_and_pretty() {
|
||||
let out = parse(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view) and pasted images: Windows CF_HDROP / PNG / CF_DIB or macOS NSPasteboard, no window or message loop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -9,8 +9,14 @@ license.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSBitmapImageRep", "NSImageRep", "NSPasteboard"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
png = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
|
||||
[dev-dependencies]
|
||||
png = "0.18"
|
||||
@@ -18,6 +18,10 @@
|
||||
//!
|
||||
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
|
||||
//! empty and [`set_files`] fails there.
|
||||
//!
|
||||
//! [`png`] reads an image (a screenshot) for the project view's Paste, which
|
||||
//! saves it as a file: `public.png` or `public.tiff` on macOS, the registered
|
||||
//! `PNG` format or `CF_DIB` on Windows. Linux reads it through GTK in the app.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -34,6 +38,19 @@ pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// The clipboard's image as PNG bytes, None when it holds none. Call on the main
|
||||
/// thread on macOS.
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
imp::png()
|
||||
}
|
||||
|
||||
/// Whether the clipboard holds an image, without reading it. Call on the main
|
||||
/// thread on macOS and Linux.
|
||||
pub fn has_image() -> bool {
|
||||
imp::has_image()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -134,9 +151,62 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `BI_RGB` and `BI_BITFIELDS`: uncompressed pixels, the forms screenshots take.
|
||||
#[cfg(any(windows, test))]
|
||||
const BI_RGB: u32 = 0;
|
||||
#[cfg(any(windows, test))]
|
||||
const BI_BITFIELDS: u32 = 3;
|
||||
|
||||
/// A packed DIB (`BITMAPINFOHEADER` or a later header, then the pixels) as PNG.
|
||||
/// Only 24- and 32-bit uncompressed bitmaps, which is what screenshots and
|
||||
/// copied images are; None for anything else. A 32-bit bitmap whose alpha is
|
||||
/// all zero (most writers leave it unset) is opaque.
|
||||
#[cfg(any(windows, test))]
|
||||
fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
||||
let u32_at = |at: usize| dib.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
let header = u32_at(0)? as usize;
|
||||
let width = u32_at(4)? as i32;
|
||||
let height = u32_at(8)? as i32;
|
||||
let bits = u16::from_le_bytes([*dib.get(14)?, *dib.get(15)?]);
|
||||
let compression = u32_at(16)?;
|
||||
if header < 40 || width <= 0 || height == 0 || !matches!(bits, 24 | 32) {
|
||||
return None;
|
||||
}
|
||||
if compression != BI_RGB && !(compression == BI_BITFIELDS && bits == 32) {
|
||||
return None;
|
||||
}
|
||||
// A `BITMAPINFOHEADER` keeps the three bitfield masks after it; later
|
||||
// headers hold them inside. Masks other than BGRA's are not supported.
|
||||
let masks = if compression == BI_BITFIELDS && header == 40 { 12 } else { 0 };
|
||||
let colors = u32_at(32)? as usize;
|
||||
let (width, rows) = (width as usize, height.unsigned_abs() as usize);
|
||||
let stride = (width * bits as usize).div_ceil(32) * 4;
|
||||
let start = header + masks + colors * 4;
|
||||
let pixels = dib.get(start..start.checked_add(stride.checked_mul(rows)?)?)?;
|
||||
let step = bits as usize / 8;
|
||||
let mut rgba = Vec::with_capacity(width * rows * 4);
|
||||
for row in 0..rows {
|
||||
// A positive height stores the bottom row first.
|
||||
let source = if height > 0 { rows - 1 - row } else { row };
|
||||
let line = &pixels[source * stride..source * stride + width * step];
|
||||
for px in line.chunks_exact(step) {
|
||||
rgba.extend_from_slice(&[px[2], px[1], px[0], if step == 4 { px[3] } else { 255 }]);
|
||||
}
|
||||
}
|
||||
if step == 4 && rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
|
||||
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|px| px[3] = 255);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, width as u32, rows as u32);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header().ok()?.write_image_data(&rgba).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::{HEADER, decode, encode, names_text};
|
||||
use super::{HEADER, decode, dib_to_png, encode, names_text};
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
@@ -164,6 +234,59 @@ mod imp {
|
||||
fn GetClipboardData(format: u32) -> Handle;
|
||||
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
|
||||
fn IsClipboardFormatAvailable(format: u32) -> i32;
|
||||
fn RegisterClipboardFormatW(name: *const u16) -> u32;
|
||||
}
|
||||
|
||||
/// `CF_DIB`: Windows synthesizes it from `CF_BITMAP` and `CF_DIBV5`, so every
|
||||
/// bitmap on the clipboard reads as one.
|
||||
const CF_DIB: u32 = 8;
|
||||
|
||||
/// The `PNG` format browsers and the Snipping Tool add beside the bitmap; it
|
||||
/// keeps transparency, so it is read first.
|
||||
fn png_format() -> u32 {
|
||||
let name: Vec<u16> = "PNG\0".encode_utf16().collect();
|
||||
unsafe { RegisterClipboardFormatW(name.as_ptr()) }
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
let png = png_format();
|
||||
(png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0)
|
||||
|| unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0
|
||||
}
|
||||
|
||||
/// A copy of the clipboard's `format` block. Call with the clipboard open.
|
||||
fn read_block(format: u32) -> Option<Vec<u8>> {
|
||||
// SAFETY: the clipboard keeps the block alive while it is open, and
|
||||
// `GlobalSize` bounds the bytes read from it.
|
||||
unsafe {
|
||||
let memory = GetClipboardData(format);
|
||||
if memory.is_null() {
|
||||
return None;
|
||||
}
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)).to_vec();
|
||||
GlobalUnlock(memory);
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
if !open() {
|
||||
return None;
|
||||
}
|
||||
let png = png_format();
|
||||
let image = if png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0 {
|
||||
read_block(png)
|
||||
} else if unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0 {
|
||||
read_block(CF_DIB).and_then(|dib| dib_to_png(&dib))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
unsafe { CloseClipboard() };
|
||||
image
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
@@ -327,8 +450,8 @@ mod imp {
|
||||
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
|
||||
use objc2::{ClassType, MainThreadMarker};
|
||||
use objc2_app_kit::{
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
|
||||
NSPasteboardWriting,
|
||||
NSBitmapImageFileType, NSBitmapImageRep, NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypePNG,
|
||||
NSPasteboardTypeString, NSPasteboardTypeTIFF, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
|
||||
use std::path::PathBuf;
|
||||
@@ -380,6 +503,33 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
|
||||
let types = NSArray::from_slice(&[unsafe { NSPasteboardTypePNG }, unsafe { NSPasteboardTypeTIFF }]);
|
||||
NSPasteboard::generalPasteboard().availableTypeFromArray(&types).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// PNG as copied, else TIFF (what macOS screenshots and most apps write) converted.
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
|
||||
if let Some(png) = board.dataForType(unsafe { NSPasteboardTypePNG }) {
|
||||
return Some(png.to_vec());
|
||||
}
|
||||
let tiff = board.dataForType(unsafe { NSPasteboardTypeTIFF })?;
|
||||
let image = NSBitmapImageRep::imageRepWithData(&tiff)?;
|
||||
// SAFETY: an empty property dictionary is valid for every file type.
|
||||
let png =
|
||||
unsafe { image.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) }?;
|
||||
Some(png.to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
@@ -448,6 +598,17 @@ mod imp {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).wait_is_image_available()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -521,4 +682,62 @@ mod tests {
|
||||
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
|
||||
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
|
||||
}
|
||||
|
||||
/// A `BITMAPINFOHEADER` DIB of `width` × `height` (positive: bottom-up).
|
||||
fn dib(width: i32, height: i32, bits: u16, compression: u32, rows: &[&[u8]]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&40u32.to_le_bytes());
|
||||
out.extend_from_slice(&width.to_le_bytes());
|
||||
out.extend_from_slice(&height.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&bits.to_le_bytes());
|
||||
out.extend_from_slice(&compression.to_le_bytes());
|
||||
out.extend_from_slice(&[0u8; 20]);
|
||||
if compression == super::BI_BITFIELDS {
|
||||
for mask in [0x00ff_0000u32, 0x0000_ff00, 0x0000_00ff] {
|
||||
out.extend_from_slice(&mask.to_le_bytes());
|
||||
}
|
||||
}
|
||||
for row in rows {
|
||||
out.extend_from_slice(row);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_png(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
|
||||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut buf).unwrap();
|
||||
buf.truncate(info.buffer_size());
|
||||
(info.width, info.height, buf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_up_24_bit_bitmap_becomes_an_upright_opaque_png() {
|
||||
// Rows are padded to 4 bytes; the first stored row is the bottom one.
|
||||
let bottom: &[u8] = &[255, 0, 0, 0, 255, 0, 0, 0];
|
||||
let top: &[u8] = &[0, 0, 255, 255, 255, 255, 0, 0];
|
||||
let png = super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[bottom, top])).unwrap();
|
||||
let (w, h, rgba) = decode_png(&png);
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_32_bit_bitmap_keeps_its_alpha_unless_none_was_written() {
|
||||
let row: &[u8] = &[10, 20, 30, 0, 40, 50, 60, 0];
|
||||
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_BITFIELDS, &[row])).unwrap());
|
||||
assert_eq!(rgba, [30, 20, 10, 255, 60, 50, 40, 255]);
|
||||
let row: &[u8] = &[10, 20, 30, 128, 40, 50, 60, 0];
|
||||
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_RGB, &[row])).unwrap());
|
||||
assert_eq!(rgba, [30, 20, 10, 128, 60, 50, 40, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_truncated_bitmaps_are_not_images() {
|
||||
let row: &[u8] = &[1, 2, 3, 4];
|
||||
assert!(super::dib_to_png(&dib(4, 1, 8, super::BI_RGB, &[row])).is_none());
|
||||
assert!(super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[row])).is_none());
|
||||
assert!(super::dib_to_png(&[0; 10]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,10 @@ pub enum IdeEvent {
|
||||
LanguageServer {
|
||||
server: ServerId,
|
||||
name: String,
|
||||
/// What the user sees as the server when another program serves in its place.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
label: Option<String>,
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! JSON with comments (`devcontainer.json`, `deno.jsonc`, `tsconfig.json`): turned
|
||||
//! into plain JSON for `serde_json`.
|
||||
|
||||
/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON.
|
||||
/// Strips `//` and `/* */` comments, trailing commas and a leading byte order mark (Visual
|
||||
/// Studio writes one into `launchSettings.json`) so JSONC parses as JSON.
|
||||
pub fn strip(src: &str) -> String {
|
||||
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut chars = src.chars().peekable();
|
||||
let mut in_str = false;
|
||||
@@ -87,4 +89,10 @@ mod tests {
|
||||
assert_eq!(v["url"], "https://x.dev//y /* no */");
|
||||
assert_eq!(v["q"], "say \"hi\",");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_a_byte_order_mark() {
|
||||
let v: serde_json::Value = serde_json::from_str(&strip("\u{feff}{ \"a\": 1 }")).unwrap();
|
||||
assert_eq!(v["a"], 1);
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,30 @@ pub fn process_tree(pid: i32) -> Vec<i32> {
|
||||
tree
|
||||
}
|
||||
|
||||
/// Every process `pid` started, directly or not, parents before their children.
|
||||
pub fn descendants(pid: u32) -> Vec<u32> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let Ok(root) = i32::try_from(pid) else { return Vec::new() };
|
||||
process_tree(root).into_iter().skip(1).filter_map(|p| u32::try_from(p).ok()).collect()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
windows_tree::descendants(pid)
|
||||
.iter()
|
||||
// SAFETY: a live process handle opened with query access.
|
||||
.map(|h| unsafe { windows_sys::Win32::System::Threading::GetProcessId(h.as_raw_handle()) })
|
||||
.filter(|&p| p != 0)
|
||||
.collect()
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
|
||||
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
|
||||
/// git command fails. Windows has no such signal to send from outside the child's console,
|
||||
|
||||
@@ -86,6 +86,8 @@ pub struct AgentSettings {
|
||||
pub fold_turns: bool,
|
||||
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
|
||||
pub smooth_text: bool,
|
||||
/// Conversations and the composer expand to the full width of the pane instead of staying in a centred column.
|
||||
pub fit_window_width: bool,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = ThinkingDisplay)]
|
||||
pub thinking: ThinkingDisplay,
|
||||
@@ -127,6 +129,7 @@ impl Default for AgentSettings {
|
||||
show_cost: false,
|
||||
fold_turns: true,
|
||||
smooth_text: true,
|
||||
fit_window_width: false,
|
||||
thinking: ThinkingDisplay::Auto,
|
||||
tool_calls: ToolCallDisplay::Grouped,
|
||||
expand_tool_output: false,
|
||||
@@ -1092,7 +1095,8 @@ impl Default for ExternalFormatterSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PythonSettings {
|
||||
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`).
|
||||
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`);
|
||||
/// `target:<run-target-id>` = the Python of a Docker or Compose run target.
|
||||
pub interpreter: String,
|
||||
/// Activate the interpreter's virtualenv in new terminal sessions.
|
||||
pub activate_virtualenv: bool,
|
||||
@@ -1121,6 +1125,17 @@ impl Default for PythonSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl PythonSettings {
|
||||
/// What `interpreter` starts with when the project interpreter is a run target's.
|
||||
pub const TARGET_PREFIX: &str = "target:";
|
||||
|
||||
/// The run target whose Python is the project interpreter (`target:<id>`), if one is.
|
||||
pub fn run_target(&self) -> Option<&str> {
|
||||
let id = self.interpreter.trim().strip_prefix(Self::TARGET_PREFIX)?.trim();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PhpSettings {
|
||||
@@ -1542,6 +1557,16 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn python_interpreter_may_name_a_run_target() {
|
||||
let python = |interpreter: &str| PythonSettings { interpreter: interpreter.into(), ..Default::default() };
|
||||
assert_eq!(python("target:web").run_target(), Some("web"));
|
||||
assert_eq!(python(" target: web ").run_target(), Some("web"));
|
||||
for local in ["", "target:", "/usr/bin/python3", ".venv/bin/python", "targets/python"] {
|
||||
assert_eq!(python(local).run_target(), None, "{local}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_one_themes_read_as_islands_themes() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
@@ -1693,11 +1718,15 @@ mod tests {
|
||||
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
|
||||
assert!(old.agents.show_cost);
|
||||
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
|
||||
assert!(!old.agents.fit_window_width);
|
||||
assert!(!Settings::default().agents.show_cost);
|
||||
assert!(!Settings::default().agents.fit_window_width);
|
||||
let mut settings = Settings::default();
|
||||
settings.agents.show_usage_limits = false;
|
||||
settings.agents.fit_window_width = true;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["agents"]["showUsageLimits"], false);
|
||||
assert_eq!(json["agents"]["fitWindowWidth"], true);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.agents, settings.agents);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
base64.workspace = true
|
||||
tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
|
||||
@@ -405,6 +405,24 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Attach to Process candidates among the processes `root` started, parents first.
|
||||
/// Blocking, like [`attachable_processes`].
|
||||
pub fn attachable_descendants(root: u32) -> IdeResult<Vec<AttachableProcess>> {
|
||||
let order = ide_core::process::descendants(root);
|
||||
if order.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let cwd = std::env::temp_dir();
|
||||
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
|
||||
let mut out: Vec<AttachableProcess> = list_processes()?
|
||||
.iter()
|
||||
.filter(|p| order.contains(&p.pid))
|
||||
.filter_map(|p| classify(p, is_dotnet, is_go_binary, native))
|
||||
.collect();
|
||||
out.sort_by_key(|p| order.iter().position(|&pid| pid == p.pid));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -418,6 +436,28 @@ mod tests {
|
||||
.map(|p| (p.kind, p.port))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_runs_candidates_are_the_processes_it_started() {
|
||||
let mut child = std::process::Command::new("sh").args(["-c", "sleep 30; true"]).spawn().unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let found = loop {
|
||||
let found = attachable_descendants(child.id()).unwrap();
|
||||
if !found.is_empty() || std::time::Instant::now() > deadline {
|
||||
break found;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
};
|
||||
for p in &found {
|
||||
let _ = std::process::Command::new("kill").arg(p.pid.to_string()).status();
|
||||
}
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
assert!(found.iter().all(|p| p.pid != child.id()), "{found:?}");
|
||||
assert!(found.iter().any(|p| p.name == "sleep" && p.kind == AttachKind::Native), "{found:?}");
|
||||
assert!(attachable_descendants(u32::MAX - 1).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_jdwp_agents() {
|
||||
let a = "java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar";
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Replacing the responses a debugged page receives. js-debug hands out a WebSocket that
|
||||
//! speaks the browser's own protocol (`requestCDPProxy`); over it the browser is told to hold
|
||||
//! the requests whose URL matches a rule (`Fetch.enable`) and each held request is answered
|
||||
//! here, with the rule's response or by letting it through.
|
||||
//!
|
||||
//! A held request stays held until it is answered, so interception is always switched off
|
||||
//! (`Fetch.disable`) before the connection is given up: a page whose requests nobody answers hangs.
|
||||
|
||||
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT, js_debug};
|
||||
use base64::Engine;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::{oneshot, watch};
|
||||
use tokio_tungstenite::tungstenite::{Message, http::StatusCode};
|
||||
|
||||
/// A response given in place of the server's to every request whose URL contains `url_contains`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsResponseRule {
|
||||
pub url_contains: String,
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// The browser's URL pattern (`*` any text, `?` one character, `\` escapes) for "contains `part`".
|
||||
pub(crate) fn url_pattern(part: &str) -> String {
|
||||
let mut pattern = String::with_capacity(part.len() + 2);
|
||||
pattern.push('*');
|
||||
for c in part.chars() {
|
||||
if matches!(c, '*' | '?' | '\\') {
|
||||
pattern.push('\\');
|
||||
}
|
||||
pattern.push(c);
|
||||
}
|
||||
pattern.push('*');
|
||||
pattern
|
||||
}
|
||||
|
||||
pub(crate) fn rule_for<'a>(rules: &'a [JsResponseRule], url: &str) -> Option<&'a JsResponseRule> {
|
||||
rules.iter().find(|rule| url.contains(&rule.url_contains))
|
||||
}
|
||||
|
||||
/// What to tell the browser about a request it holds: the first matching rule's response, or
|
||||
/// to send the request on. Every held request gets one of the two.
|
||||
pub(crate) fn answer(held: &Value, rules: &[JsResponseRule]) -> (&'static str, Value) {
|
||||
let request_id = held.get("requestId").and_then(Value::as_str).unwrap_or_default();
|
||||
let url = held.pointer("/request/url").and_then(Value::as_str).unwrap_or_default();
|
||||
let Some(rule) = rule_for(rules, url) else {
|
||||
return ("Fetch.continueRequest", json!({ "requestId": request_id }));
|
||||
};
|
||||
let header = |name: &str, value: &str| json!({ "name": name, "value": value });
|
||||
// A page on another origin only reads a response that names its origin. The server would
|
||||
// say so; the response given in its place has to.
|
||||
let mut cross_origin = Vec::new();
|
||||
if let Some(origin) = request_header(held, "Origin") {
|
||||
cross_origin.push(header("Access-Control-Allow-Origin", origin));
|
||||
cross_origin.push(header("Access-Control-Allow-Credentials", "true"));
|
||||
}
|
||||
let method = held.pointer("/request/method").and_then(Value::as_str).unwrap_or_default();
|
||||
// The browser's question before such a request: allowed here, the server may not exist.
|
||||
if method == "OPTIONS"
|
||||
&& let Some(asked) = request_header(held, "Access-Control-Request-Method")
|
||||
{
|
||||
cross_origin.push(header("Access-Control-Allow-Methods", asked));
|
||||
if let Some(asked) = request_header(held, "Access-Control-Request-Headers") {
|
||||
cross_origin.push(header("Access-Control-Allow-Headers", asked));
|
||||
}
|
||||
let allowed = json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": 204,
|
||||
"responsePhrase": phrase(204),
|
||||
"responseHeaders": cross_origin,
|
||||
});
|
||||
return ("Fetch.fulfillRequest", allowed);
|
||||
}
|
||||
let mut headers = vec![header("Content-Type", &rule.content_type)];
|
||||
headers.extend(cross_origin);
|
||||
let replaced = json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": rule.status,
|
||||
"responsePhrase": phrase(rule.status),
|
||||
"responseHeaders": headers,
|
||||
"body": base64::engine::general_purpose::STANDARD.encode(rule.body.as_bytes()),
|
||||
});
|
||||
("Fetch.fulfillRequest", replaced)
|
||||
}
|
||||
|
||||
/// A header of the held request; the browser reports names as the page wrote them.
|
||||
fn request_header<'a>(held: &'a Value, name: &str) -> Option<&'a str> {
|
||||
held.pointer("/request/headers")?.as_object()?.iter().find(|(key, _)| key.eq_ignore_ascii_case(name))?.1.as_str()
|
||||
}
|
||||
|
||||
/// The browser refuses a response without a phrase when it has none of its own for the status.
|
||||
fn phrase(status: u16) -> &'static str {
|
||||
StatusCode::from_u16(status).ok().and_then(|status| status.canonical_reason()).unwrap_or("Unknown")
|
||||
}
|
||||
|
||||
fn interception(rules: &[JsResponseRule]) -> Value {
|
||||
let patterns: Vec<Value> = rules
|
||||
.iter()
|
||||
.map(|rule| json!({ "urlPattern": url_pattern(&rule.url_contains), "requestStage": "Request" }))
|
||||
.collect();
|
||||
json!({ "patterns": patterns })
|
||||
}
|
||||
|
||||
/// Applies `rules` to the page behind js-debug's CDP proxy at `ws_url` until their sender is
|
||||
/// dropped (the session is gone) or the proxy closes the connection. The browser holds
|
||||
/// requests only while there are rules. `ready` hears whether the proxy reports held
|
||||
/// requests at all; nothing is held before it has agreed to.
|
||||
pub(crate) async fn serve(
|
||||
ws_url: String,
|
||||
mut rules: watch::Receiver<Vec<JsResponseRule>>,
|
||||
ready: oneshot::Sender<Result<(), String>>,
|
||||
) {
|
||||
let socket = match tokio_tungstenite::connect_async(ws_url.as_str()).await {
|
||||
Ok((socket, _)) => socket,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (mut write, mut read) = socket.split();
|
||||
let mut last_id = 0u64;
|
||||
let mut call = |method: &str, params: Value| {
|
||||
last_id += 1;
|
||||
(last_id, Message::text(json!({ "id": last_id, "method": method, "params": params }).to_string()))
|
||||
};
|
||||
|
||||
let (subscription, subscribe) = call("JsDebug.subscribe", json!({ "events": ["Fetch.requestPaused"] }));
|
||||
let subscribed = tokio::time::timeout(REQUEST_TIMEOUT, async {
|
||||
write.send(subscribe).await.map_err(|e| e.to_string())?;
|
||||
loop {
|
||||
let reply = read.next().await.ok_or("the connection closed")?.map_err(|e| e.to_string())?;
|
||||
let Some(reply) = reply.to_text().ok().and_then(|text| serde_json::from_str::<Value>(text).ok()) else {
|
||||
continue;
|
||||
};
|
||||
if reply.get("id").and_then(Value::as_u64) != Some(subscription) {
|
||||
continue;
|
||||
}
|
||||
break match reply.get("error") {
|
||||
Some(error) => Err(error.get("message").and_then(Value::as_str).unwrap_or("refused").to_owned()),
|
||||
None => Ok(()),
|
||||
};
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("no answer".to_owned()));
|
||||
if let Err(why) = subscribed {
|
||||
let _ = ready.send(Err(why));
|
||||
let _ = write.close().await;
|
||||
return;
|
||||
}
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
let mut current = rules.borrow_and_update().clone();
|
||||
let mut holding = !current.is_empty();
|
||||
if holding && write.send(call("Fetch.enable", interception(¤t)).1).await.is_err() {
|
||||
return;
|
||||
}
|
||||
// The request each sent replacement is for, by command id, until the browser has taken it.
|
||||
let mut replacing: HashMap<u64, String> = HashMap::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = rules.changed() => {
|
||||
if changed.is_err() {
|
||||
break;
|
||||
}
|
||||
current = rules.borrow_and_update().clone();
|
||||
let (_, command) = match (current.is_empty(), holding) {
|
||||
(true, false) => continue,
|
||||
(true, true) => call("Fetch.disable", json!({})),
|
||||
(false, _) => call("Fetch.enable", interception(¤t)),
|
||||
};
|
||||
holding = !current.is_empty();
|
||||
if write.send(command).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
message = read.next() => {
|
||||
// The proxy closed (the page or the session ended): nothing is held any more.
|
||||
let Some(Ok(message)) = message else { return };
|
||||
let Ok(text) = message.to_text() else { continue };
|
||||
let Ok(message) = serde_json::from_str::<Value>(text) else { continue };
|
||||
if let Some(id) = message.get("id").and_then(Value::as_u64) {
|
||||
// The browser did not take the replacement: let the request through, or
|
||||
// it stays held and the page waits for it.
|
||||
if let Some(request_id) = replacing.remove(&id)
|
||||
&& message.get("error").is_some()
|
||||
{
|
||||
let (_, go_on) = call("Fetch.continueRequest", json!({ "requestId": request_id }));
|
||||
if write.send(go_on).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if message.get("method").and_then(Value::as_str) != Some("Fetch.requestPaused") {
|
||||
continue;
|
||||
}
|
||||
let Some(held) = message.get("params") else { continue };
|
||||
let Some(request_id) = held.get("requestId").and_then(Value::as_str) else { continue };
|
||||
let (method, params) = answer(held, ¤t);
|
||||
let (id, command) = call(method, params);
|
||||
if method == "Fetch.fulfillRequest" {
|
||||
replacing.insert(id, request_id.to_owned());
|
||||
}
|
||||
if write.send(command).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if holding {
|
||||
let _ = write.send(call("Fetch.disable", json!({})).1).await;
|
||||
}
|
||||
let _ = write.close().await;
|
||||
}
|
||||
|
||||
impl DebugManager {
|
||||
/// Makes `rules` the page's response rules; none switches the replacing off. The first
|
||||
/// rules of a session open the connection to js-debug's CDP proxy, later ones go over it.
|
||||
/// An error means the rules are not applied.
|
||||
pub async fn js_set_response_overrides(&self, s: DebugSessionId, rules: Vec<JsResponseRule>) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
if !js_debug::is_page(&session) {
|
||||
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
|
||||
}
|
||||
// Held to the end: two callers at once would each open a connection.
|
||||
let mut applied = session.js_overrides.lock().await;
|
||||
if applied.as_ref().is_some_and(|sender| sender.send(rules.clone()).is_ok()) {
|
||||
return Ok(());
|
||||
}
|
||||
// No connection yet, or the proxy closed it.
|
||||
applied.take();
|
||||
if rules.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let refused =
|
||||
|why: String| IdeError::unavailable(format!("the JavaScript debugger has no browser connection: {why}"));
|
||||
if !js_debug::launched(&session).await {
|
||||
return Err(refused("the page's debug session did not finish starting".into()));
|
||||
}
|
||||
let proxy = session
|
||||
.conn
|
||||
.request_timeout("requestCDPProxy", json!({}), REQUEST_TIMEOUT)
|
||||
.await
|
||||
.map_err(|e| refused(e.to_string()))?;
|
||||
let (Some(host), Some(port)) =
|
||||
(proxy.get("host").and_then(Value::as_str), proxy.get("port").and_then(Value::as_u64))
|
||||
else {
|
||||
return Err(refused("no address".into()));
|
||||
};
|
||||
let path = proxy.get("path").and_then(Value::as_str).unwrap_or_default();
|
||||
let (sender, receiver) = watch::channel(rules);
|
||||
let (ready, is_ready) = oneshot::channel();
|
||||
tokio::spawn(serve(format!("ws://{host}:{port}{path}"), receiver, ready));
|
||||
match is_ready.await {
|
||||
Ok(Ok(())) => {
|
||||
*applied = Some(sender);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(why)) => Err(refused(why)),
|
||||
Err(_) => Err(refused("no answer".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
pub(crate) fn rule(url_contains: &str, status: u16, body: &str) -> JsResponseRule {
|
||||
JsResponseRule {
|
||||
url_contains: url_contains.into(),
|
||||
status,
|
||||
content_type: "application/json".into(),
|
||||
body: body.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_of_a_url_becomes_a_pattern_that_matches_it_anywhere() {
|
||||
assert_eq!(url_pattern("api/items"), "*api/items*");
|
||||
// The pattern's own wildcards and escape character are taken literally.
|
||||
assert_eq!(url_pattern(r"a*b?c\d"), r"*a\*b\?c\\d*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_rule_whose_text_is_in_the_url_answers() {
|
||||
let rules = [rule("api/items", 201, "{\"a\":\"é\"}"), rule("api", 500, "later")];
|
||||
assert_eq!(rule_for(&rules, "http://h/api/items?x=1").map(|r| r.status), Some(201));
|
||||
assert_eq!(rule_for(&rules, "http://h/api/users").map(|r| r.status), Some(500));
|
||||
assert!(rule_for(&rules, "http://h/logo.png").is_none());
|
||||
|
||||
let (command, params) = answer(&held("7", "http://h/api/items")["params"], &rules);
|
||||
assert_eq!(command, "Fetch.fulfillRequest");
|
||||
assert_eq!(
|
||||
params,
|
||||
json!({
|
||||
"requestId": "7",
|
||||
"responseCode": 201,
|
||||
"responsePhrase": "Created",
|
||||
"responseHeaders": [{ "name": "Content-Type", "value": "application/json" }],
|
||||
// base64 of the UTF-8 text `{"a":"é"}`
|
||||
"body": "eyJhIjoiw6kifQ==",
|
||||
})
|
||||
);
|
||||
// A request the browser held that no rule is for goes on to the network.
|
||||
assert_eq!(
|
||||
answer(&held("8", "http://h/logo.png")["params"], &rules),
|
||||
("Fetch.continueRequest", json!({ "requestId": "8" }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_the_browser_has_no_phrase_for_is_given_one() {
|
||||
// The browser refuses a response whose status it cannot name.
|
||||
let (_, params) = answer(&held("1", "http://h/api")["params"], &[rule("api", 419, "")]);
|
||||
assert_eq!((¶ms["responseCode"], ¶ms["responsePhrase"]), (&json!(419), &json!("Unknown")));
|
||||
}
|
||||
|
||||
fn header<'a>(params: &'a Value, name: &str) -> Option<&'a str> {
|
||||
params["responseHeaders"].as_array()?.iter().find(|h| h["name"] == name)?["value"].as_str()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_response_passes_the_pages_cross_origin_check() {
|
||||
let rules = [rule("api/items", 200, "[]")];
|
||||
let from_page = |method: &str, headers: Value| json!({ "requestId": "3", "request": { "url": "http://localhost:8080/api/items", "method": method, "headers": headers } });
|
||||
// A page on another origin: the response names that origin, as the real server would.
|
||||
let (_, params) = answer(&from_page("GET", json!({ "origin": "http://localhost:5173" })), &rules);
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Credentials"), Some("true"));
|
||||
assert_eq!(params["body"], "W10=");
|
||||
|
||||
// The browser's question before such a request is answered too: the server may not exist.
|
||||
let preflight = from_page(
|
||||
"OPTIONS",
|
||||
json!({
|
||||
"Origin": "http://localhost:5173",
|
||||
"Access-Control-Request-Method": "PUT",
|
||||
"Access-Control-Request-Headers": "content-type,x-token",
|
||||
}),
|
||||
);
|
||||
let (command, params) = answer(&preflight, &rules);
|
||||
assert_eq!((command, ¶ms["responseCode"]), ("Fetch.fulfillRequest", &json!(204)));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Methods"), Some("PUT"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Headers"), Some("content-type,x-token"));
|
||||
assert_eq!(params.get("body"), None);
|
||||
|
||||
// Same origin: nothing but the rule's own header.
|
||||
let (_, params) = answer(&from_page("GET", json!({})), &rules);
|
||||
assert_eq!(params["responseHeaders"], json!([{ "name": "Content-Type", "value": "application/json" }]));
|
||||
}
|
||||
|
||||
/// A stand-in for js-debug's CDP proxy: what it receives, and a way to send it events.
|
||||
pub(crate) async fn proxy() -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
|
||||
proxy_refusing(|_| false).await
|
||||
}
|
||||
|
||||
/// A proxy that answers the commands `refused` picks with an error, like the real one does
|
||||
/// for what it or the browser does not accept.
|
||||
pub(crate) async fn proxy_refusing(
|
||||
refused: fn(&Value) -> bool,
|
||||
) -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
|
||||
let (received_tx, received) = mpsc::unbounded_channel();
|
||||
let (events, mut events_rx) = mpsc::unbounded_channel::<Value>();
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let (mut write, mut read) = tokio_tungstenite::accept_async(stream).await.unwrap().split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = read.next() => {
|
||||
let Some(Ok(message)) = message else { break };
|
||||
let Ok(text) = message.to_text() else { continue };
|
||||
let Ok(request) = serde_json::from_str::<Value>(text) else { continue };
|
||||
let reply = if refused(&request) {
|
||||
json!({ "id": request["id"], "error": { "code": -32602, "message": "refused" } })
|
||||
} else {
|
||||
json!({ "id": request["id"], "result": {} })
|
||||
};
|
||||
let _ = write.send(Message::text(reply.to_string())).await;
|
||||
let _ = received_tx.send(request);
|
||||
}
|
||||
Some(event) = events_rx.recv() => {
|
||||
let _ = write.send(Message::text(event.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(url, received, events)
|
||||
}
|
||||
|
||||
pub(crate) async fn next(received: &mut mpsc::UnboundedReceiver<Value>) -> (String, Value) {
|
||||
let request = tokio::time::timeout(std::time::Duration::from_secs(5), received.recv())
|
||||
.await
|
||||
.expect("the proxy got a request in time")
|
||||
.expect("the connection is open");
|
||||
(request["method"].as_str().unwrap().to_owned(), request["params"].clone())
|
||||
}
|
||||
|
||||
/// The browser's event for a request it holds.
|
||||
fn held(request_id: &str, url: &str) -> Value {
|
||||
json!({ "method": "Fetch.requestPaused", "params": { "requestId": request_id, "request": { "url": url } } })
|
||||
}
|
||||
|
||||
type Serving = (tokio::task::JoinHandle<()>, oneshot::Receiver<Result<(), String>>);
|
||||
|
||||
fn serving(url: String, rules: watch::Receiver<Vec<JsResponseRule>>) -> Serving {
|
||||
let (ready, is_ready) = oneshot::channel();
|
||||
(tokio::spawn(serve(url, rules, ready)), is_ready)
|
||||
}
|
||||
|
||||
async fn ended(task: tokio::task::JoinHandle<()>) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), task).await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn held_requests_are_answered_and_interception_ends_before_the_connection() {
|
||||
let (url, mut received, events) = proxy().await;
|
||||
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api/items", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert_eq!(ready.await.unwrap(), Ok(()));
|
||||
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
("JsDebug.subscribe".into(), json!({ "events": ["Fetch.requestPaused"] }))
|
||||
);
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
(
|
||||
"Fetch.enable".into(),
|
||||
json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] })
|
||||
)
|
||||
);
|
||||
|
||||
events.send(held("1", "http://h/api/items")).unwrap();
|
||||
let (command, params) = next(&mut received).await;
|
||||
assert_eq!(
|
||||
(command.as_str(), ¶ms["requestId"], ¶ms["responseCode"]),
|
||||
("Fetch.fulfillRequest", &json!("1"), &json!(200))
|
||||
);
|
||||
events.send(held("2", "http://h/other")).unwrap();
|
||||
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "2" })));
|
||||
|
||||
// New rules replace the browser's patterns.
|
||||
rules.send(vec![rule("api/users", 404, "{}")]).unwrap();
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
(
|
||||
"Fetch.enable".into(),
|
||||
json!({ "patterns": [{ "urlPattern": "*api/users*", "requestStage": "Request" }] })
|
||||
)
|
||||
);
|
||||
|
||||
// No rules left: the browser stops holding requests. The connection stays for the
|
||||
// rules that come next, and goes with the session.
|
||||
rules.send(Vec::new()).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
rules.send(vec![rule("api/items", 200, "[]")]).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
rules.send(Vec::new()).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
drop(rules);
|
||||
ended(serving).await;
|
||||
// Already off: not switched off a second time.
|
||||
assert!(received.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_that_goes_away_switches_interception_off() {
|
||||
let (url, mut received, _events) = proxy().await;
|
||||
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, _ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
drop(rules);
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
ended(serving).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_response_the_browser_refuses_lets_the_request_through() {
|
||||
let (url, mut received, events) = proxy_refusing(|request| request["method"] == "Fetch.fulfillRequest").await;
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (_serving, _ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
events.send(held("9", "http://h/api")).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.fulfillRequest");
|
||||
// Unanswered, the request would hang the page.
|
||||
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "9" })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_proxy_that_does_not_report_held_requests_is_not_asked_to_hold_any() {
|
||||
let (url, mut received, _events) = proxy_refusing(|request| request["method"] == "JsDebug.subscribe").await;
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(ready.await.unwrap(), Err("refused".to_owned()));
|
||||
ended(serving).await;
|
||||
assert!(received.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_connection_to_the_proxy_is_reported() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
|
||||
drop(listener);
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert!(ready.await.unwrap().is_err());
|
||||
ended(serving).await;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
pub(crate) const ADAPTER: &str = "js-debug";
|
||||
|
||||
@@ -25,7 +27,7 @@ pub(crate) fn is_page(session: &Session) -> bool {
|
||||
/// Waits until the adapter answered the `launch` of `session`. js-debug sets a page's session
|
||||
/// up between `configurationDone` and that answer; a request of its own that arrives then is
|
||||
/// answered and has no effect.
|
||||
async fn launched(session: &Session) -> bool {
|
||||
pub(crate) async fn launched(session: &Session) -> bool {
|
||||
let mut launched = session.launched.subscribe();
|
||||
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
|
||||
matches!(answered, Ok(Ok(_)))
|
||||
@@ -59,6 +61,44 @@ impl DebugManager {
|
||||
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts recording the page's CPU profile; [`Self::js_stop_profile`] ends it.
|
||||
pub async fn js_start_profile(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.js_page_request(s, "startProfile", json!({ "type": "cpu" })).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends the recording and returns a new directory holding it as `page.cpuprofile`
|
||||
/// (what `ide-profiler` reads a directory of).
|
||||
pub async fn js_stop_profile(&self, s: DebugSessionId) -> IdeResult<PathBuf> {
|
||||
let session = self.session(s)?;
|
||||
// js-debug names the file it records into when the recording starts.
|
||||
let Some(recorded) = session.js_profile.lock().take() else {
|
||||
return Err(IdeError::invalid("no CPU profile is being recorded"));
|
||||
};
|
||||
self.js_page_request(s, "stopProfile", json!({})).await?;
|
||||
static HANDED_OVER: AtomicU32 = AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openidea-js-profile-{}-{}-{}",
|
||||
std::process::id(),
|
||||
s.0,
|
||||
HANDED_OVER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let copied = tokio::task::spawn_blocking({
|
||||
let dir = dir.clone();
|
||||
move || {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::copy(&recorded, dir.join("page.cpuprofile"))?;
|
||||
// The adapter's own copy is in the system's temporary directory: no use keeping two.
|
||||
let _ = std::fs::remove_file(&recorded);
|
||||
Ok::<_, std::io::Error>(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
copied.map_err(|e| IdeError::unavailable(format!("the recorded CPU profile cannot be read: {e}")))?;
|
||||
Ok(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod js_cdp;
|
||||
mod js_debug;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
@@ -24,6 +25,7 @@ use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
pub use js_cdp::JsResponseRule;
|
||||
pub use js_debug::JsResponseBody;
|
||||
pub use jvm_memory::*;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
@@ -94,6 +94,11 @@ pub(crate) struct Session {
|
||||
/// The `type` of the launch or attach request (js-debug's `pwa-node`, `pwa-chrome`); a child
|
||||
/// session's comes from the configuration the adapter started it with.
|
||||
pub launch_type: std::sync::OnceLock<String>,
|
||||
/// The response rules of a page once a connection applies them (`js_cdp::serve`).
|
||||
/// Dropped with the session, which switches the browser's interception off.
|
||||
pub js_overrides: tokio::sync::Mutex<Option<tokio::sync::watch::Sender<Vec<crate::JsResponseRule>>>>,
|
||||
/// The file js-debug records the page's CPU profile into, while one is being recorded.
|
||||
pub js_profile: parking_lot::Mutex<Option<std::path::PathBuf>>,
|
||||
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
|
||||
/// One inspector request per session at a time.
|
||||
pub inspect_lock: tokio::sync::Mutex<()>,
|
||||
@@ -255,6 +260,8 @@ impl Session {
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
jdwp_link: parking_lot::Mutex::new(None),
|
||||
launch_type: std::sync::OnceLock::new(),
|
||||
js_overrides: tokio::sync::Mutex::new(None),
|
||||
js_profile: parking_lot::Mutex::new(None),
|
||||
jvm_inspector: parking_lot::Mutex::new(None),
|
||||
inspect_lock: tokio::sync::Mutex::default(),
|
||||
ended: ide_core::CancellationToken::new(),
|
||||
@@ -842,6 +849,10 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
s.emit(DebugEvent::JsNetwork { event });
|
||||
}
|
||||
}
|
||||
// js-debug, after `startProfile`: where it records the profile.
|
||||
"profileStarted" => {
|
||||
*s.js_profile.lock() = str_("file").map(std::path::PathBuf::from);
|
||||
}
|
||||
_ => tracing::trace!("dap: ignored event {event}"),
|
||||
}
|
||||
}
|
||||
@@ -872,6 +883,7 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: ide_core::input::InputWriter,
|
||||
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pid: Option<u32>,
|
||||
/// Set once the program has exited and been reaped.
|
||||
exited: Arc<AtomicBool>,
|
||||
@@ -884,6 +896,7 @@ impl ProgramTerminal {
|
||||
}
|
||||
|
||||
/// The program's process id while it runs.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) fn process_id(&self) -> Option<u32> {
|
||||
if self.exited.load(Ordering::Acquire) { None } else { self.pid }
|
||||
}
|
||||
|
||||
@@ -1104,6 +1104,142 @@ async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
|
||||
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
|
||||
}
|
||||
|
||||
/// Where the fake adapter says its CDP proxy listens (`requestCDPProxy`).
|
||||
static CDP_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
|
||||
|
||||
fn proxy_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
let port = CDP_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
(msg["command"] == "requestCDPProxy")
|
||||
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_reach_the_page_through_the_adapters_proxy() {
|
||||
use crate::js_cdp::tests::{next, proxy, rule};
|
||||
let (url, mut received, _events) = proxy().await;
|
||||
let port: u16 = url.trim_start_matches("ws://127.0.0.1:").trim_end_matches("/session").parse().unwrap();
|
||||
CDP_PROXY_PORT.store(port, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let h = harness(Script {
|
||||
start_debugging: true,
|
||||
custom: Some(proxy_adapter),
|
||||
child_launch_delay: Duration::from_millis(150),
|
||||
..Script::default()
|
||||
});
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
|
||||
// Rules that are there as the page starts wait until its session takes requests.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
|
||||
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
|
||||
assert_eq!(dropped, 0, "the proxy was asked for before the page took requests");
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
("Fetch.enable".into(), json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] }))
|
||||
);
|
||||
// Changed rules go over the connection that is open: the proxy is asked for once.
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/users", 200, "[]")]).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.1["patterns"][0]["urlPattern"], "*api/users*");
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
|
||||
|
||||
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
// A rule switched back on.
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
|
||||
|
||||
let not_a_page = h.manager.js_set_response_overrides(browser, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_are_unavailable_without_the_adapters_proxy() {
|
||||
use crate::js_cdp::tests::rule;
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
let refused = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
// Nothing to switch off is not an error.
|
||||
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
|
||||
}
|
||||
|
||||
/// A port nothing listens on, which the fake adapter gives as its CDP proxy.
|
||||
static DEAD_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
|
||||
|
||||
fn dead_proxy_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
let port = DEAD_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
(msg["command"] == "requestCDPProxy")
|
||||
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_that_cannot_reach_the_browser_are_an_error() {
|
||||
use crate::js_cdp::tests::rule;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
DEAD_PROXY_PORT.store(listener.local_addr().unwrap().port(), std::sync::atomic::Ordering::Relaxed);
|
||||
drop(listener);
|
||||
|
||||
let h = harness(Script { start_debugging: true, custom: Some(dead_proxy_adapter), ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
let unreachable = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(unreachable.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
// The next rules try again instead of going to a connection that never was.
|
||||
let again = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(again.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 2);
|
||||
}
|
||||
|
||||
/// The file the fake adapter says it records a CPU profile into (`profileStarted`).
|
||||
static PROFILE_FILE: parking_lot::Mutex<String> = parking_lot::Mutex::new(String::new());
|
||||
|
||||
fn profiling_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
match msg["command"].as_str()? {
|
||||
"startProfile" => {
|
||||
Some((json!({}), vec![("profileStarted", json!({ "file": *PROFILE_FILE.lock(), "type": "cpu" }))]))
|
||||
}
|
||||
"stopProfile" => Some((json!({}), vec![("profilerStateUpdate", json!({ "label": "", "running": false }))])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pages_cpu_profile_is_handed_over_as_a_cpuprofile_file() {
|
||||
let recorded = std::env::temp_dir().join(format!("openidea-dap-profile-{}", std::process::id()));
|
||||
std::fs::write(&recorded, r#"{"nodes":[],"samples":[]}"#).unwrap();
|
||||
*PROFILE_FILE.lock() = recorded.to_string_lossy().into_owned();
|
||||
|
||||
let h = harness(Script { start_debugging: true, custom: Some(profiling_adapter), ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
|
||||
// Nothing is being recorded yet.
|
||||
let idle = h.manager.js_stop_profile(page).await.unwrap_err();
|
||||
assert_eq!(idle.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
|
||||
h.manager.js_start_profile(page).await.unwrap();
|
||||
assert_eq!(requests(&h, "startProfile"), [json!({ "type": "cpu" })]);
|
||||
let dir = h.manager.js_stop_profile(page).await.unwrap();
|
||||
assert_eq!(requests(&h, "stopProfile").len(), 1);
|
||||
let files: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(files, ["page.cpuprofile"]);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("page.cpuprofile")).unwrap(), r#"{"nodes":[],"samples":[]}"#);
|
||||
// One recording is handed over once.
|
||||
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
|
||||
// The adapter's file isn't there when it is asked for (the hand-over above took the first one).
|
||||
assert!(!recorded.exists());
|
||||
h.manager.js_start_profile(page).await.unwrap();
|
||||
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
|
||||
assert_eq!(h.manager.js_start_profile(browser).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
|
||||
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
|
||||
|
||||
@@ -542,6 +542,24 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
/// Runs the docker CLI with `args` in `dir` and returns its standard output; a non-zero
|
||||
/// exit is an error with docker's message.
|
||||
pub fn cli(
|
||||
ep: &DockerEndpoint,
|
||||
dir: Option<&Path>,
|
||||
args: &[String],
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<String> {
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let mut cmd = command(ep, &args)?;
|
||||
if let Some(dir) = dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let out = process::run(cmd, timeout, cancel)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.8.0";
|
||||
pub const ENGINE_VERSION: &str = "1.9.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use super::page::{OFFSET, Page};
|
||||
use super::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{Diagnostic, IdeResult, Severity};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// Problems `get_project_problems` lists by default.
|
||||
const PROBLEMS_LIMIT: usize = 100;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![
|
||||
@@ -37,7 +41,10 @@ Lines and UTF-16 columns are 1-based.",
|
||||
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
|
||||
Lines and UTF-16 columns are 1-based.",
|
||||
project_problems,
|
||||
),
|
||||
)
|
||||
.param(Param::optional("errorsOnly", ParamType::Boolean, "Only errors (default) or warnings too"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Problems to return at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -90,9 +97,25 @@ async fn file_problems(call: Call) -> IdeResult<Output> {
|
||||
}
|
||||
|
||||
async fn project_problems(call: Call) -> IdeResult<Output> {
|
||||
let page = Page::of(&call, PROBLEMS_LIMIT)?;
|
||||
let errors_only = call.flag("errorsOnly", true)?;
|
||||
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
|
||||
let files: Vec<_> = files.into_iter().map(|f| json!({
|
||||
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
|
||||
})).collect();
|
||||
Output::json(json!({"files":files}))
|
||||
let problems: Vec<(String, Value)> = files
|
||||
.into_iter()
|
||||
.flat_map(|f| {
|
||||
let path = call.display(&f.path.0);
|
||||
let wanted = f.items.into_iter().filter(|d| !errors_only || d.severity == Severity::Error);
|
||||
wanted.map(move |d| (path.clone(), problem(d, "")))
|
||||
})
|
||||
.collect();
|
||||
let mut out = Map::new();
|
||||
let mut files: Vec<Value> = Vec::new();
|
||||
for (path, problem) in page.take(problems, &mut out) {
|
||||
match files.last_mut() {
|
||||
Some(file) if file["filePath"] == path.as_str() => file["problems"].as_array_mut().unwrap().push(problem),
|
||||
_ => files.push(json!({ "filePath": path, "problems": [problem] })),
|
||||
}
|
||||
}
|
||||
out.insert("files".into(), files.into());
|
||||
Output::json(Value::Object(out))
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
|
||||
//! Reading files and listing directories.
|
||||
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use crate::mcp::util::{
|
||||
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
|
||||
@@ -17,6 +18,8 @@ const MAX_READ_LIMIT: i64 = 5000;
|
||||
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
|
||||
const MAX_LINE_LENGTH: usize = 2000;
|
||||
const DEFAULT_TREE_DEPTH: i64 = 5;
|
||||
/// Entries `list_directory_tree` lists by default.
|
||||
const TREE_LIMIT: usize = 100;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![read_file(), list_directory_tree()]
|
||||
@@ -149,6 +152,12 @@ struct DirectoryTreeInfo {
|
||||
errors: Vec<String>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
listing_timed_out: bool,
|
||||
/// Entries under the directory within `maxDepth`.
|
||||
total: usize,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
more: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_offset: Option<usize>,
|
||||
}
|
||||
|
||||
fn list_directory_tree() -> Tool {
|
||||
@@ -156,10 +165,12 @@ fn list_directory_tree() -> Tool {
|
||||
"list_directory_tree",
|
||||
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
|
||||
Use this tool to explore the contents of a directory or the whole project.
|
||||
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
|
||||
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.
|
||||
A page that starts inside a directory repeats the lines of the directories above it.",
|
||||
|call| async move {
|
||||
let directory = call.str("directoryPath")?;
|
||||
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
|
||||
let page = Page::of(&call, TREE_LIMIT)?;
|
||||
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
|
||||
let dir = call.resolve_readable(&directory).await?;
|
||||
if !dir.exists() {
|
||||
@@ -170,8 +181,16 @@ You MUST prefer this tool over listing directories via command line utilities li
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
|
||||
let cancel = call.cancel.clone();
|
||||
let mut tree =
|
||||
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
|
||||
let mut tree = TreeRender {
|
||||
out: String::new(),
|
||||
errors: Vec::new(),
|
||||
timed_out: false,
|
||||
deadline,
|
||||
cancel: cancel.clone(),
|
||||
page,
|
||||
seen: 0,
|
||||
above: Vec::new(),
|
||||
};
|
||||
let tree = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
@@ -187,11 +206,16 @@ You MUST prefer this tool over listing directories via command line utilities li
|
||||
tree: tree.out,
|
||||
errors: tree.errors,
|
||||
listing_timed_out: tree.timed_out,
|
||||
total: tree.seen,
|
||||
more: tree.seen > page.end(),
|
||||
next_offset: (tree.seen > page.end()).then_some(page.end()),
|
||||
})
|
||||
},
|
||||
)
|
||||
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
|
||||
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Entries to return at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
|
||||
.output(json!({
|
||||
"type": "object",
|
||||
@@ -200,19 +224,28 @@ You MUST prefer this tool over listing directories via command line utilities li
|
||||
"tree": { "type": "string" },
|
||||
"errors": { "type": "array", "items": { "type": "string" } },
|
||||
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
|
||||
"total": { "type": "integer" },
|
||||
"more": { "type": "boolean" },
|
||||
"nextOffset": { "type": "integer" },
|
||||
},
|
||||
"required": ["traversedDirectory", "tree", "errors"],
|
||||
"required": ["traversedDirectory", "tree", "errors", "total"],
|
||||
}))
|
||||
}
|
||||
|
||||
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
|
||||
/// ignored ones too) by lower-cased name, directories marked with `/`.
|
||||
/// ignored ones too) by lower-cased name, directories marked with `/`; entries outside the
|
||||
/// page are counted, not listed.
|
||||
struct TreeRender {
|
||||
out: String,
|
||||
errors: Vec<String>,
|
||||
timed_out: bool,
|
||||
deadline: Instant,
|
||||
cancel: CancellationToken,
|
||||
page: Page,
|
||||
/// Entries met so far.
|
||||
seen: usize,
|
||||
/// The lines of the directories above the entry, and whether this page shows them yet.
|
||||
above: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
impl TreeRender {
|
||||
@@ -235,7 +268,13 @@ impl TreeRender {
|
||||
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
|
||||
};
|
||||
let is_dir = path.is_dir() && !path.is_symlink();
|
||||
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
|
||||
let line = format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" });
|
||||
let shown = if is_root {
|
||||
self.out.push_str(&line);
|
||||
true
|
||||
} else {
|
||||
self.entry(&line)
|
||||
};
|
||||
if !is_dir {
|
||||
return;
|
||||
}
|
||||
@@ -250,9 +289,32 @@ impl TreeRender {
|
||||
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
|
||||
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
|
||||
let n = children.len();
|
||||
if !is_root {
|
||||
self.above.push((line, shown));
|
||||
}
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
|
||||
}
|
||||
if !is_root {
|
||||
self.above.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts an entry and lists it when on the page, after the directories above it.
|
||||
fn entry(&mut self, line: &str) -> bool {
|
||||
let n = self.seen;
|
||||
self.seen += 1;
|
||||
if n < self.page.offset || n >= self.page.end() {
|
||||
return false;
|
||||
}
|
||||
for (above, shown) in &mut self.above {
|
||||
if !*shown {
|
||||
self.out.push_str(above);
|
||||
*shown = true;
|
||||
}
|
||||
}
|
||||
self.out.push_str(line);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,4 +364,36 @@ mod tests {
|
||||
let short = format!("{}😀", "x".repeat(1998));
|
||||
assert_eq!(format_line(&short), short);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_page_repeats_the_directories_above_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for file in ["a/x.txt", "a/y.txt", "b.txt"] {
|
||||
let path = tmp.path().join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, "").unwrap();
|
||||
}
|
||||
let render = |offset, limit| {
|
||||
let mut tree = TreeRender {
|
||||
out: String::new(),
|
||||
errors: Vec::new(),
|
||||
timed_out: false,
|
||||
deadline: Instant::now() + Duration::from_secs(10),
|
||||
cancel: CancellationToken::new(),
|
||||
page: Page { offset, limit },
|
||||
seen: 0,
|
||||
above: Vec::new(),
|
||||
};
|
||||
tree.render(tmp.path(), "", true, 5, true);
|
||||
let root = format!("{}/\n", tmp.path().display());
|
||||
(tree.out.strip_prefix(&root).unwrap().to_owned(), tree.seen)
|
||||
};
|
||||
assert_eq!(render(0, 2), (" ├── a/\n │ ├── x.txt\n".to_owned(), 4));
|
||||
assert_eq!(
|
||||
render(2, 2).0,
|
||||
" ├── a/\n │ └── y.txt\n └── b.txt\n",
|
||||
"the page in `a/` repeats its line"
|
||||
);
|
||||
assert_eq!(render(0, 100).0, " ├── a/\n │ ├── x.txt\n │ └── y.txt\n └── b.txt\n");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub(crate) mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
mod page;
|
||||
mod reports;
|
||||
mod search;
|
||||
mod symbols;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `limit` and `offset` of the tools that list things: a few items by default, the rest page by page.
|
||||
|
||||
use super::tool::Call;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// The most items one call returns.
|
||||
pub const MAX_LIMIT: usize = 1000;
|
||||
pub const OFFSET: &str = "Items to skip: `nextOffset` of the previous page";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Page {
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// The `limit` (`default` when absent, at most [`MAX_LIMIT`]) and `offset` (0) arguments.
|
||||
pub fn of(call: &Call, default: usize) -> IdeResult<Self> {
|
||||
let limit = match call.opt_i64("limit")? {
|
||||
None => default,
|
||||
Some(n) if n <= 0 => return Err(IdeError::invalid("limit must be > 0")),
|
||||
Some(n) => (n as usize).min(MAX_LIMIT),
|
||||
};
|
||||
let offset = match call.opt_i64("offset")? {
|
||||
None => 0,
|
||||
Some(n) if n < 0 => return Err(IdeError::invalid("offset must be >= 0")),
|
||||
Some(n) => n as usize,
|
||||
};
|
||||
Ok(Self { offset, limit })
|
||||
}
|
||||
|
||||
/// Where the page ends: the items to find for it.
|
||||
pub fn end(&self) -> usize {
|
||||
self.offset.saturating_add(self.limit)
|
||||
}
|
||||
|
||||
/// This page of all the `items`; `out` gets their `total`, and `more` and `nextOffset` when some follow.
|
||||
pub fn take<T>(&self, items: Vec<T>, out: &mut Map<String, Value>) -> Vec<T> {
|
||||
let total = items.len();
|
||||
out.insert("total".into(), total.into());
|
||||
self.mark(total > self.end(), out);
|
||||
items.into_iter().skip(self.offset).take(self.limit).collect()
|
||||
}
|
||||
|
||||
/// `more` and `nextOffset` when items follow the page.
|
||||
pub fn mark(&self, more: bool, out: &mut Map<String, Value>) {
|
||||
if more {
|
||||
out.insert("more".into(), true.into());
|
||||
out.insert("nextOffset".into(), self.end().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pages_say_what_follows() {
|
||||
let page = |offset, limit| Page { offset, limit };
|
||||
let mut out = Map::new();
|
||||
assert_eq!(page(0, 2).take(vec![1, 2, 3, 4, 5], &mut out), [1, 2]);
|
||||
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5, "more": true, "nextOffset": 2 }));
|
||||
let mut out = Map::new();
|
||||
assert_eq!(page(4, 2).take(vec![1, 2, 3, 4, 5], &mut out), [5]);
|
||||
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5 }), "the last page");
|
||||
let mut out = Map::new();
|
||||
assert!(page(9, 2).take(vec![1], &mut out).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
|
||||
use super::output::RUN_PREVIEW_MAX_LENGTH;
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -35,6 +36,14 @@ and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as
|
||||
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
|
||||
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
|
||||
(keeps the start), MIDDLE (keeps both ends) or NONE";
|
||||
/// `list_runs`: tabs returned by default, and characters of each command, enough to tell them apart.
|
||||
const LIST_LIMIT: usize = 10;
|
||||
const LIST_COMMAND_CHARS: usize = 80;
|
||||
const LIST_STATES: [(&str, &[&str]); 3] = [
|
||||
("running", &["starting", "running", "stopping"]),
|
||||
("idle", &["succeeded", "failed", "crashed", "stoppedByAgent", "stoppedByUser", "startFailed"]),
|
||||
("error", &["failed", "crashed", "startFailed"]),
|
||||
];
|
||||
|
||||
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
@@ -283,13 +292,64 @@ for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
|
||||
pub fn list_runs() -> Tool {
|
||||
Tool::new(
|
||||
"list_runs",
|
||||
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|
||||
"Lists the agent tabs of the project (those started by `run_command`), newest first: name, state, the \
|
||||
start of the command, exit code and times. `get_run_status` gives a tab's whole status.",
|
||||
|call| async move {
|
||||
let runs: Vec<Value> =
|
||||
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
|
||||
Output::json(serde_json::json!({ "runs": runs }))
|
||||
let page = Page::of(&call, LIST_LIMIT)?;
|
||||
let wanted = call.opt_str("state")?.filter(|s| s != "all");
|
||||
let states = match wanted.as_deref() {
|
||||
None => None,
|
||||
Some(s) => Some(match LIST_STATES.iter().find(|(group, _)| *group == s) {
|
||||
Some((_, states)) => states.to_vec(),
|
||||
None if LIST_STATES[1].1.contains(&s) || LIST_STATES[0].1.contains(&s) => vec![s],
|
||||
None => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"unknown state `{s}`: all, running, idle, error, or one of the states of get_run_status"
|
||||
)));
|
||||
}
|
||||
}),
|
||||
};
|
||||
let runs: Vec<Value> = call
|
||||
.host()
|
||||
.mcp_agent_runs
|
||||
.list(call.root())
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|r| r.status())
|
||||
.filter(|s| {
|
||||
states.as_ref().is_none_or(|states| s["state"].as_str().is_some_and(|v| states.contains(&v)))
|
||||
})
|
||||
.map(|s| Value::Object(brief(s)))
|
||||
.collect();
|
||||
let mut out = Map::new();
|
||||
let runs = page.take(runs, &mut out);
|
||||
out.insert("runs".into(), runs.into());
|
||||
Output::json(Value::Object(out))
|
||||
},
|
||||
)
|
||||
.param(Param::optional(
|
||||
"state",
|
||||
ParamType::String,
|
||||
"all (default), running, idle (not running), error (failed, crashed, startFailed), or one state",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Tabs to return at most (default 10)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
}
|
||||
|
||||
/// A tab in `list_runs`: what tells it apart and how it went, the command cut short.
|
||||
fn brief(mut status: Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for key in ["name", "state", "command", "exitCode", "crash", "error", "startedAt", "endedAt", "tabClosedAt"] {
|
||||
if let Some(v) = status.remove(key) {
|
||||
out.insert(key.into(), v);
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(command)) = out.get_mut("command")
|
||||
&& command.chars().count() > LIST_COMMAND_CHARS
|
||||
{
|
||||
*command = format!("{}…", command.chars().take(LIST_COMMAND_CHARS).collect::<String>());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What a key does to a command on pipes.
|
||||
@@ -407,6 +467,22 @@ pub fn tools() -> Vec<Tool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn listed_tabs_are_brief_and_their_commands_cut_short() {
|
||||
let status = serde_json::json!({
|
||||
"name": "_agent_build", "state": "failed", "command": "x".repeat(100), "exitCode": 1,
|
||||
"workingDirectory": "", "outputLength": 9, "startedBy": "agent",
|
||||
});
|
||||
let Value::Object(status) = status else { unreachable!() };
|
||||
let brief = Value::Object(brief(status));
|
||||
assert_eq!(brief["command"].as_str().unwrap(), format!("{}…", "x".repeat(LIST_COMMAND_CHARS)));
|
||||
assert_eq!(
|
||||
(&brief["name"], &brief["state"], &brief["exitCode"]),
|
||||
(&"_agent_build".into(), &"failed".into(), &1.into())
|
||||
);
|
||||
assert!(brief.get("outputLength").is_none() && brief.get("workingDirectory").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_what_pipes_can_take() {
|
||||
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::output::OutputCollector;
|
||||
use super::process::{quote, split_words};
|
||||
use super::targets;
|
||||
use crate::Project;
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_build::compile::BuildKind;
|
||||
@@ -19,6 +20,8 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 10_000;
|
||||
/// Configurations `get_run_configurations` lists by default.
|
||||
const CONFIGURATIONS_LIMIT: usize = 100;
|
||||
/// Output of a failed before-launch process kept for the error message.
|
||||
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
|
||||
/// A before-launch build gets this long.
|
||||
@@ -50,7 +53,8 @@ fn describe_config(call: &Call, c: &RunConfig) -> Value {
|
||||
if let Some(cwd) = c.cwd.as_deref() {
|
||||
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
|
||||
}
|
||||
if !c.env.is_empty() {
|
||||
// A detected configuration's environment is the IDE's own (its tools on `PATH`).
|
||||
if !c.env.is_empty() && !c.detected {
|
||||
v["environment"] = json!(c.env);
|
||||
}
|
||||
}
|
||||
@@ -133,8 +137,23 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
|
||||
.await?;
|
||||
return Output::json(json!({ "filePath": file, "runPoints": points }));
|
||||
}
|
||||
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
|
||||
Output::json(json!({ "configurations": list }))
|
||||
let page = Page::of(&call, CONFIGURATIONS_LIMIT)?;
|
||||
let query = call.opt_str("query")?.map(|q| q.to_lowercase());
|
||||
let list: Vec<Value> = configs
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
query.as_ref().is_none_or(|q| {
|
||||
[c.name.as_str(), c.kind.as_str(), command_line(c).as_str()]
|
||||
.iter()
|
||||
.any(|s| s.to_lowercase().contains(q.as_str()))
|
||||
})
|
||||
})
|
||||
.map(|c| describe_config(&call, c))
|
||||
.collect();
|
||||
let mut out = Map::new();
|
||||
let list = page.take(list, &mut out);
|
||||
out.insert("configurations".into(), list.into());
|
||||
Output::json(Value::Object(out))
|
||||
},
|
||||
)
|
||||
.write()
|
||||
@@ -144,6 +163,9 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
|
||||
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
|
||||
in the file instead of project-wide run configurations.",
|
||||
))
|
||||
.param(Param::optional("query", ParamType::String, "Only configurations whose name, type or command contains it"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Configurations to return at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
}
|
||||
|
||||
/// What `execute_run_configuration` runs.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
|
||||
//! the editor (one per character except emoji and other astral characters).
|
||||
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
|
||||
use crate::ops::search::SearchEvent;
|
||||
@@ -21,8 +22,8 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
|
||||
const DEFAULT_LIMIT: i64 = 1000;
|
||||
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
|
||||
const DEFAULT_LIMIT: usize = 100;
|
||||
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: usize = 50;
|
||||
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
|
||||
"The search timed out after 10 s. Narrow the query or pass paths.";
|
||||
/// Symbols requested from the index per result when `paths` filters them afterwards.
|
||||
@@ -37,7 +38,8 @@ const LINE_WINDOW: usize = 500;
|
||||
|
||||
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
|
||||
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
|
||||
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
|
||||
const LIMIT_DESCRIPTION: &str = "Results to return at most (default 100)";
|
||||
const SYMBOL_LIMIT_DESCRIPTION: &str = "Results to return at most (default 50)";
|
||||
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
|
||||
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
|
||||
each side. At most 50. Default: the matched line only.";
|
||||
@@ -67,6 +69,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
.param(Param::required("q", ParamType::String, "Text to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
@@ -88,6 +91,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
@@ -113,7 +117,8 @@ If you don't find a suitable result, try again with include_external=true to sea
|
||||
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
|
||||
with include_external=true.",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, SYMBOL_LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
@@ -138,6 +143,7 @@ Paths are optional additional glob filters relative to the project root.",
|
||||
"Whether to include excluded/ignored files in results",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -191,6 +197,9 @@ struct SearchResult {
|
||||
items: Vec<SearchItem>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
more: bool,
|
||||
/// The `offset` of the next page, when `more`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_offset: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
partial_result_reason: Option<&'static str>,
|
||||
}
|
||||
@@ -212,16 +221,23 @@ fn result_schema() -> Value {
|
||||
"required": ["filePath"],
|
||||
} },
|
||||
"more": { "type": "boolean" },
|
||||
"nextOffset": int,
|
||||
"partialResultReason": string,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
|
||||
match call.opt_i64("limit")?.unwrap_or(default) {
|
||||
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
|
||||
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
|
||||
}
|
||||
/// The page of `call` and the results to find for it: up to its end.
|
||||
fn page(call: &Call, default: usize) -> IdeResult<(Page, usize)> {
|
||||
let page = Page::of(call, default)?;
|
||||
Ok((page, page.end().min(MAX_RESULTS_UPPER_BOUND)))
|
||||
}
|
||||
|
||||
/// Drops the results before the page, and the `nextOffset` when more follow. A search finds
|
||||
/// [`MAX_RESULTS_UPPER_BOUND`] results at most, so no page starts past them.
|
||||
fn skip<T>(page: Page, items: &mut Vec<T>, more: bool) -> Option<usize> {
|
||||
items.drain(..page.offset.min(items.len()));
|
||||
(more && page.end() < MAX_RESULTS_UPPER_BOUND).then_some(page.end())
|
||||
}
|
||||
|
||||
fn query(call: &Call, empty: &str) -> IdeResult<String> {
|
||||
@@ -521,7 +537,7 @@ struct TextHit {
|
||||
|
||||
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
|
||||
let limit = limit(&call, DEFAULT_LIMIT)?;
|
||||
let (page, limit) = page(&call, DEFAULT_LIMIT)?;
|
||||
let around = context(&call)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
// Globs Find in Files cannot take are applied afterwards, so the engine must not
|
||||
@@ -612,11 +628,12 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let next_offset = skip(page, &mut items, more);
|
||||
let absolute: std::collections::HashMap<&str, &Path> =
|
||||
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
|
||||
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
|
||||
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
|
||||
}
|
||||
|
||||
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
|
||||
@@ -721,7 +738,7 @@ async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatch
|
||||
|
||||
async fn file_search(call: Call) -> IdeResult<Output> {
|
||||
let q = query(&call, "Glob pattern is empty")?;
|
||||
let limit = limit(&call, DEFAULT_LIMIT)?;
|
||||
let (page, limit) = page(&call, DEFAULT_LIMIT)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
let include_excluded = call.flag("includeExcluded", false)?;
|
||||
let glob = GlobSetBuilder::new()
|
||||
@@ -759,9 +776,11 @@ async fn file_search(call: Call) -> IdeResult<Output> {
|
||||
Ok((found, more, walk.timed_out.get()))
|
||||
})
|
||||
.await?;
|
||||
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
|
||||
let mut items: Vec<SearchItem> =
|
||||
paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
|
||||
let next_offset = skip(page, &mut items, more);
|
||||
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
|
||||
}
|
||||
|
||||
struct Walk {
|
||||
@@ -835,7 +854,7 @@ impl Walk {
|
||||
async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
let started = Instant::now();
|
||||
let q = query(&call, "Search query is empty")?;
|
||||
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
|
||||
let (page, limit) = page(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
|
||||
let around = context(&call)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
let external = call.flag("include_external", false)?;
|
||||
@@ -886,6 +905,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
}
|
||||
let more = items.len() > limit || timed_out;
|
||||
items.truncate(limit);
|
||||
let next_offset = skip(page, &mut items, more);
|
||||
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
|
||||
let root = call.root().to_path_buf();
|
||||
let absolute = |item: &SearchItem| {
|
||||
@@ -899,7 +919,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
} else {
|
||||
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
|
||||
};
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
|
||||
}
|
||||
|
||||
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
|
||||
|
||||
@@ -210,6 +210,7 @@ fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
|
||||
pub fn find_references() -> Tool {
|
||||
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
|
||||
let path = call.resolve_readable(&call.str("filePath")?).await?;
|
||||
let page = super::page::Page::of(&call, REFERENCES_LIMIT)?;
|
||||
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
|
||||
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
|
||||
let project = call.project.clone();
|
||||
@@ -244,14 +245,28 @@ pub fn find_references() -> Tool {
|
||||
}
|
||||
}
|
||||
positions(&mut value);
|
||||
// `classified`, when there, goes with `locations` one to one.
|
||||
if let serde_json::Value::Object(map) = &mut value {
|
||||
let mut list = |key: &str| match map.remove(key) { Some(serde_json::Value::Array(items)) => items, _ => Vec::new() };
|
||||
let (locations, classified) = (list("locations"), list("classified"));
|
||||
let classified = page.take(classified, &mut serde_json::Map::new());
|
||||
let locations = page.take(locations, map);
|
||||
map.insert("locations".into(), locations.into());
|
||||
map.insert("classified".into(), classified.into());
|
||||
}
|
||||
Output::json(value)
|
||||
})
|
||||
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
|
||||
.param(Param::required("line", ParamType::Integer, "1-based line"))
|
||||
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
|
||||
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "References to return at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, super::page::OFFSET))
|
||||
}
|
||||
|
||||
/// References `find_references` lists by default.
|
||||
const REFERENCES_LIMIT: usize = 100;
|
||||
|
||||
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
|
||||
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
|
||||
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
|
||||
//! Version control: repositories and git status.
|
||||
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const STATUS_DEFAULT_LIMIT: i64 = 1000;
|
||||
const STATUS_DEFAULT_LIMIT: usize = 100;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![get_repositories(), git_status(), git_diff()]
|
||||
@@ -254,11 +255,7 @@ By default all Git repositories are returned.",
|
||||
|call| async move {
|
||||
let include_untracked = call.flag("includeUntracked", true)?;
|
||||
let include_ignored = call.flag("includeIgnored", false)?;
|
||||
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
|
||||
if limit <= 0 {
|
||||
return Err(IdeError::invalid("limit must be > 0"));
|
||||
}
|
||||
let limit = limit as usize;
|
||||
let page = Page::of(&call, STATUS_DEFAULT_LIMIT)?;
|
||||
let host = call.host();
|
||||
let h = host.clone();
|
||||
let w = call.window.clone();
|
||||
@@ -305,7 +302,8 @@ By default all Git repositories are returned.",
|
||||
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
|
||||
let listed: Vec<Value> = entries
|
||||
.iter()
|
||||
.take(limit)
|
||||
.skip(page.offset)
|
||||
.take(page.limit)
|
||||
.map(|e| {
|
||||
let mut v = json!({
|
||||
"pathRelativeToRepository": e.path.trim_end_matches('/'),
|
||||
@@ -318,19 +316,24 @@ By default all Git repositories are returned.",
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
repositories.push(json!({
|
||||
let more = entries.len() > page.end();
|
||||
let mut repository = json!({
|
||||
"repositoryPathRelativeToProject": rel,
|
||||
"currentBranch": branch,
|
||||
"isClean": entries.is_empty(),
|
||||
"totalEntries": entries.len(),
|
||||
"hasMoreEntries": entries.len() > listed.len(),
|
||||
"hasMoreEntries": more,
|
||||
"stagedCount": count(StatusEntry::staged),
|
||||
"unstagedCount": count(StatusEntry::unstaged),
|
||||
"untrackedCount": count(StatusEntry::untracked),
|
||||
"ignoredCount": count(StatusEntry::ignored),
|
||||
"conflictedCount": count(StatusEntry::conflicted),
|
||||
"entries": listed,
|
||||
}));
|
||||
});
|
||||
if more {
|
||||
repository["nextOffset"] = page.end().into();
|
||||
}
|
||||
repositories.push(repository);
|
||||
}
|
||||
Output::json(json!({ "repositories": repositories }))
|
||||
},
|
||||
@@ -342,7 +345,8 @@ By default all Git repositories are returned.",
|
||||
))
|
||||
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
|
||||
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Entries to return per repository at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
}
|
||||
|
||||
fn git_diff() -> Tool {
|
||||
|
||||
@@ -261,6 +261,18 @@ pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -
|
||||
host.project(window)?.agents.fork(&task, turns).await
|
||||
}
|
||||
|
||||
/// Creates a temporary side conversation of `source`: lives in memory only, never
|
||||
/// persisted, not listed.
|
||||
pub async fn create_ephemeral(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: NewAgentTask,
|
||||
source: String,
|
||||
) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.create_ephemeral(request, &source).await
|
||||
}
|
||||
|
||||
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
|
||||
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
|
||||
host.require_trust(window)?;
|
||||
|
||||
@@ -382,6 +382,15 @@ pub async fn debug_js_custom_breakpoints(
|
||||
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_response_overrides(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
rules: Vec<ide_dap::JsResponseRule>,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.js_set_response_overrides(session, rules).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_response_body(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
@@ -391,6 +400,18 @@ pub async fn debug_js_response_body(
|
||||
host.debugger.js_response_body(session, request_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_start_profile(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.js_start_profile(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_stop_profile(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
) -> IdeResult<ide_core::IpcPath> {
|
||||
host.debugger.js_stop_profile(session).await.map(Into::into)
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
@@ -607,12 +628,23 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
|
||||
d.add("debug_js_response_overrides", |c| async move {
|
||||
debug_js_response_overrides(&c.host, &c.window, c.arg("session")?, c.arg("rules")?).await
|
||||
});
|
||||
d.add("debug_js_response_body", |c| async move {
|
||||
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
|
||||
});
|
||||
d.add("debug_js_custom_breakpoints", |c| async move {
|
||||
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_js_start_profile",
|
||||
|c| async move { debug_js_start_profile(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
d.add(
|
||||
"debug_js_stop_profile",
|
||||
|c| async move { debug_js_stop_profile(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
d.add("debug_set_function_breakpoints", |c| async move {
|
||||
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
|
||||
@@ -21,6 +21,28 @@ pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
|
||||
/// through [`save_as`], which turns it into a regular file document. `name`
|
||||
/// re-references an already minted buffer instead of creating one.
|
||||
pub async fn open_untitled(host: &Host, window: &str, name: Option<String>) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.open_untitled(name.as_deref())).await
|
||||
}
|
||||
|
||||
/// Save As for an untitled buffer ([`Documents::save_as`]); LightEdit windows
|
||||
/// start watching the saved file's folder.
|
||||
pub async fn save_as(host: &Host, window: &str, doc: DocId, path: PathBuf) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let info = project.documents.save_as(doc, &path)?;
|
||||
if let Some(dir) = path.parent().filter(|_| project.light) {
|
||||
project.watch_light_dir(dir);
|
||||
}
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
|
||||
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
host.project(window)?.documents.edit(doc, base_version, changes)
|
||||
|
||||
@@ -50,7 +50,10 @@ pub async fn doc_set_encoding(
|
||||
reload: bool,
|
||||
) -> IdeResult<DocumentEncoding> {
|
||||
let project = host.project(window)?;
|
||||
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
|
||||
if reload
|
||||
&& !project.documents.is_untitled(doc)?
|
||||
&& let Some(uri) = project.documents.virtual_uri(doc)?
|
||||
{
|
||||
let vd = if uri.starts_with("git-revision:") {
|
||||
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
|
||||
} else {
|
||||
@@ -159,6 +162,15 @@ pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<
|
||||
.await
|
||||
}
|
||||
|
||||
/// Python class facts the inheritance inspections read: base names as written, a method's
|
||||
/// parameters, decorator semantics and its body shape. Tree-sitter answers; no LSP.
|
||||
pub async fn doc_python_facts(host: &Host, window: &str, doc: DocId) -> IdeResult<ide_language::PythonFacts> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
blocking(move || Ok(syntax.python_facts(&snap.language, &snap.text))).await
|
||||
}
|
||||
|
||||
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
|
||||
/// Large documents fall back to word / line / document ranges.
|
||||
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
|
||||
@@ -208,6 +220,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_python_facts", |c| async move { doc_python_facts(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_selection_ranges", |c| async move {
|
||||
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
|
||||
});
|
||||
|
||||
@@ -1472,6 +1472,15 @@ pub async fn lsp_type_hierarchy(
|
||||
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_type_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<HierarchyItem>> {
|
||||
host.project(window)?.lsp.prepare_type_hierarchy(doc, pos).await
|
||||
}
|
||||
|
||||
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
|
||||
Ok(host.project(window)?.lsp.servers())
|
||||
}
|
||||
@@ -1856,6 +1865,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_type_hierarchy", |c| async move {
|
||||
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
});
|
||||
d.add("lsp_prepare_type_hierarchy", |c| async move {
|
||||
lsp_prepare_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
|
||||
@@ -239,6 +239,12 @@ pub fn set_settings(
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
// A run target chosen as the Python interpreter (or changed) needs a snapshot of its libraries.
|
||||
if ["languages", "languages.python", "languages.python.interpreter", "runTargets"].contains(&key) {
|
||||
for project in affected() {
|
||||
crate::ops::sdk::container_python::ensure(host, &project);
|
||||
}
|
||||
}
|
||||
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
|
||||
if code_style || toolchain {
|
||||
for project in affected() {
|
||||
|
||||
@@ -508,7 +508,7 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
|
||||
}
|
||||
|
||||
mod commands;
|
||||
mod container;
|
||||
pub(crate) mod container;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -33,23 +33,11 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
|
||||
let root = root.to_path_buf();
|
||||
let data_dir = host.data_dir.clone();
|
||||
host.blocking(move || {
|
||||
if !ide_docker::endpoint_is_local(&endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
require_local(&endpoint, &target)?;
|
||||
let docker = ide_docker::docker_executable().ok_or_else(|| {
|
||||
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
|
||||
})?;
|
||||
let container_root = match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), &root)?
|
||||
}
|
||||
"" => default_container_root(&root),
|
||||
folder => folder.to_owned(),
|
||||
};
|
||||
let container_root = container_root(&endpoint, &root, &target)?;
|
||||
let file_env = env_files(&root, &config)?;
|
||||
let name = container_name(&config.id, &unique());
|
||||
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
|
||||
@@ -75,6 +63,29 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
|
||||
.await
|
||||
}
|
||||
|
||||
/// The project can only be bind-mounted from a daemon on this machine.
|
||||
pub(crate) fn require_local(endpoint: &DockerEndpoint, target: &RunTarget) -> IdeResult<()> {
|
||||
if !ide_docker::endpoint_is_local(endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the project is in the target's containers.
|
||||
pub(crate) fn container_root(endpoint: &DockerEndpoint, root: &Path, target: &RunTarget) -> IdeResult<String> {
|
||||
Ok(match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(endpoint, root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), root)?
|
||||
}
|
||||
"" => default_container_root(root),
|
||||
folder => folder.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a debug run mounts and publishes.
|
||||
struct Debugging {
|
||||
port: u16,
|
||||
@@ -104,7 +115,7 @@ fn free_port() -> IdeResult<u16> {
|
||||
}
|
||||
|
||||
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
|
||||
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
pub(crate) fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
let id = target.docker_connection.trim();
|
||||
if id.is_empty() {
|
||||
return Ok(DockerEndpoint::Default);
|
||||
|
||||
@@ -10,6 +10,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
pub(crate) mod container_python;
|
||||
pub use container_python::{ContainerPython, sdk_container_python, sdk_container_python_refresh};
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
@@ -232,12 +235,17 @@ pub async fn sdk_project_libraries(
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let container = container_python::library_group(host, &root, &settings);
|
||||
let libraries = {
|
||||
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
|
||||
if let Some(group) = &container {
|
||||
let _ = on_groups.send(vec![group.clone()]);
|
||||
}
|
||||
let groups = ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
|
||||
let _ = on_groups.send(groups);
|
||||
})
|
||||
});
|
||||
container.into_iter().chain(groups).collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
let dependencies = {
|
||||
@@ -377,6 +385,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
|
||||
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
|
||||
d.add("sdk_container_python", |c| async move { sdk_container_python(&c.host, &c.window) });
|
||||
d.add("sdk_container_python_refresh", |c| async move { sdk_container_python_refresh(&c.host, &c.window).await });
|
||||
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! A Docker or Compose run target's Python as the project interpreter: snapshots of the
|
||||
//! container's library folders for the language server (see `ide_sdk::container_python`).
|
||||
//!
|
||||
//! A snapshot runs the target's Python once in a new container (the image's entrypoint
|
||||
//! and the service's dependencies skipped), then copies its `sys.path` folders out of
|
||||
//! the stopped container with `docker cp`. Nothing is installed in the container.
|
||||
|
||||
use crate::Host;
|
||||
use crate::project::Project;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, TaskId};
|
||||
use ide_sdk::container_python::{self as snapshots, Snapshot};
|
||||
use ide_sdk::python::PythonPackage;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Building or pulling the image may come first.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||
const COPY_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const QUICK: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The project interpreter when it is a run target's.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContainerPython {
|
||||
/// The run target's id.
|
||||
pub target: String,
|
||||
/// Its name; empty when no run target has the id.
|
||||
pub name: String,
|
||||
/// The Python version of the snapshot in use; `None`: no snapshot yet.
|
||||
pub version: Option<String>,
|
||||
/// The image the snapshot was copied from.
|
||||
pub image: Option<String>,
|
||||
/// The copied library folders on this machine.
|
||||
pub folders: Vec<IpcPath>,
|
||||
/// Packages of the snapshot (read-only).
|
||||
pub packages: Vec<PythonPackage>,
|
||||
/// A snapshot is being taken.
|
||||
pub updating: bool,
|
||||
}
|
||||
|
||||
/// Where snapshots are kept, and the Docker connections the targets refer to.
|
||||
#[derive(Clone)]
|
||||
struct Dirs {
|
||||
data: PathBuf,
|
||||
/// `<local data>/container-python/`: `<project>/<target>/<image>/` and `neutral/`.
|
||||
cache: PathBuf,
|
||||
}
|
||||
|
||||
impl Dirs {
|
||||
fn of(host: &Host) -> Self {
|
||||
Self::new(&host.data_dir, &host.local_data_dir)
|
||||
}
|
||||
|
||||
fn new(data_dir: &Path, local_data_dir: &Path) -> Self {
|
||||
Self { data: data_dir.to_path_buf(), cache: local_data_dir.join("container-python") }
|
||||
}
|
||||
|
||||
fn target(&self, root: &Path, target: &str) -> PathBuf {
|
||||
snapshots::target_dir(&self.cache.join(crate::project::project_key(root)), target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Target folders with a snapshot being taken.
|
||||
static UPDATING: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn updating(dir: &Path) -> bool {
|
||||
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).contains(dir)
|
||||
}
|
||||
|
||||
/// The snapshot in use for the project's interpreter, if it is a run target's.
|
||||
fn current(dirs: &Dirs, root: &Path, settings: &Settings) -> Option<(PathBuf, Snapshot)> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
snapshots::current(&dirs.target(root, target))
|
||||
}
|
||||
|
||||
/// Language server configuration: basedpyright / pyright see the container's libraries.
|
||||
pub(crate) fn configure_server(
|
||||
cfg: &mut ide_language::LanguageServerConfig,
|
||||
local_data_dir: &Path,
|
||||
root: &Path,
|
||||
settings: &Settings,
|
||||
) {
|
||||
if settings.languages.python.run_target().is_none() {
|
||||
return;
|
||||
}
|
||||
let current = current(&Dirs::new(local_data_dir, local_data_dir), root, settings);
|
||||
snapshots::configure_server(cfg, current.as_ref().map(|(d, s)| (d.as_path(), s)));
|
||||
}
|
||||
|
||||
/// External Libraries: the snapshot's folders, named after the run target.
|
||||
pub(crate) fn library_group(host: &Host, root: &Path, settings: &Settings) -> Option<ide_sdk::libraries::LibraryGroup> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
let (dir, snapshot) = current(&Dirs::of(host), root, settings)?;
|
||||
let name = settings.run_targets.iter().find(|t| t.id == target).map_or(target, |t| t.name.as_str());
|
||||
snapshots::library_group(name, &dir, &snapshot)
|
||||
}
|
||||
|
||||
/// The project interpreter when it is a run target's, with its snapshot.
|
||||
pub fn sdk_container_python(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
Ok(describe(host, &project.root, &project.settings.effective()))
|
||||
}
|
||||
|
||||
fn describe(host: &Host, root: &Path, settings: &Settings) -> Option<ContainerPython> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
let name = settings.run_targets.iter().find(|t| t.id == target).map(|t| t.name.clone()).unwrap_or_default();
|
||||
let dir = Dirs::of(host).target(root, target);
|
||||
let mut out = ContainerPython {
|
||||
target: target.to_owned(),
|
||||
name,
|
||||
version: None,
|
||||
image: None,
|
||||
folders: Vec::new(),
|
||||
packages: Vec::new(),
|
||||
updating: updating(&dir),
|
||||
};
|
||||
if let Some((dir, snapshot)) = snapshots::current(&dir) {
|
||||
out.folders = snapshots::local_folders(&dir, &snapshot).into_iter().map(Into::into).collect();
|
||||
out.packages = snapshots::packages(&dir, &snapshot);
|
||||
out.version = Some(snapshot.probe.version);
|
||||
out.image = Some(snapshot.image);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Takes a new snapshot of the project interpreter's container (also of an image that
|
||||
/// has one already), then the language servers pick it up.
|
||||
pub async fn sdk_container_python_refresh(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
update(Dirs::of(host), &project, true).await?;
|
||||
Ok(describe(host, &project.root, &project.settings.effective()))
|
||||
}
|
||||
|
||||
/// Takes a snapshot when the project interpreter is a run target's and the current image
|
||||
/// has none yet (a new choice, a rebuilt image), in the background. Errors are notified.
|
||||
pub(crate) fn ensure(host: &Host, project: &Arc<Project>) {
|
||||
if !project.is_trusted() || project.settings.effective().languages.python.run_target().is_none() {
|
||||
return;
|
||||
}
|
||||
let (dirs, project) = (Dirs::of(host), project.clone());
|
||||
host.runtime.spawn(async move {
|
||||
if let Err(e) = update(dirs, &project, false).await
|
||||
&& e.kind != ide_core::error::ErrorKind::Cancelled
|
||||
{
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Warning,
|
||||
title: "Cannot copy the container's Python libraries".into(),
|
||||
body: Some(e.message),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn update(dirs: Dirs, project: &Arc<Project>, force: bool) -> IdeResult<()> {
|
||||
let settings = project.settings.effective();
|
||||
let Some(id) = settings.languages.python.run_target() else { return Ok(()) };
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind == RunTargetKind::Ssh {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' runs over SSH: only a Docker or Compose run target can be the project interpreter",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let dir = dirs.target(&project.root, id);
|
||||
if !UPDATING.lock().unwrap_or_else(|e| e.into_inner()).insert(dir.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
let _done = Updating(dir.clone());
|
||||
let endpoint = crate::ops::run::container::endpoint(&dirs.data, &target)?;
|
||||
let task = TaskId::next();
|
||||
let title = format!("Python libraries of {}", target.name);
|
||||
let (root, sink) = (project.root.clone(), project.sink.clone());
|
||||
let progress = move |message: &str, done: bool| {
|
||||
sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: title.clone(),
|
||||
message: Some(message.to_owned()),
|
||||
fraction: None,
|
||||
done,
|
||||
cancellable: false,
|
||||
})
|
||||
};
|
||||
let report = progress.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
take(&endpoint, &root, &target, &dir, &dirs.cache, force, &|message| report(message, false))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
.and_then(|r| r);
|
||||
progress(if result.is_ok() { "Done" } else { "Failed" }, true);
|
||||
if result? {
|
||||
project.lsp.reconfigure().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Updating(PathBuf);
|
||||
|
||||
impl Drop for Updating {
|
||||
fn drop(&mut self) {
|
||||
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the probe container, also when copying failed.
|
||||
struct Container<'a> {
|
||||
endpoint: &'a ide_docker::DockerEndpoint,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Drop for Container<'_> {
|
||||
fn drop(&mut self) {
|
||||
let args = ["rm", "--force", "--volumes", &self.name].map(String::from);
|
||||
if let Err(e) = ide_docker::cli(self.endpoint, None, &args, QUICK, None) {
|
||||
tracing::warn!("cannot remove container {}: {}", self.name, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the snapshot in `target_dir` and makes it the one in use; `false` when the image
|
||||
/// had one already and it is in use (unless `force`).
|
||||
fn take(
|
||||
endpoint: &ide_docker::DockerEndpoint,
|
||||
root: &Path,
|
||||
target: &RunTarget,
|
||||
target_dir: &Path,
|
||||
cache: &Path,
|
||||
force: bool,
|
||||
progress: &dyn Fn(&str),
|
||||
) -> IdeResult<bool> {
|
||||
crate::ops::run::container::require_local(endpoint, target)?;
|
||||
let container_root = crate::ops::run::container::container_root(endpoint, root, target)?;
|
||||
let name = format!("openidea-python-{}", unique());
|
||||
let args = ide_remote::docker::python_probe_args(target, root, &name, snapshots::PROBE)?;
|
||||
progress("Starting the container's Python");
|
||||
let container = Container { endpoint, name };
|
||||
let out = ide_docker::cli(endpoint, Some(root), &args, PROBE_TIMEOUT, None);
|
||||
let probe = snapshots::parse_probe(&out?)?;
|
||||
let inspect = ["inspect".into(), "--format".into(), "{{.Image}}".into(), container.name.clone()];
|
||||
let image = ide_docker::cli(endpoint, None, &inspect, QUICK, None)?.trim().to_owned();
|
||||
if image.is_empty() {
|
||||
return Err(IdeError::internal(format!("docker inspect printed no image for {}", container.name)));
|
||||
}
|
||||
let dir = target_dir.join(snapshots::dir_name(&image));
|
||||
let before = snapshots::current(target_dir).map(|(_, s)| s.image);
|
||||
if !force && snapshots::read(&dir).is_some() {
|
||||
snapshots::make_current(target_dir, &image)?;
|
||||
return Ok(before.as_deref() != Some(image.as_str()));
|
||||
}
|
||||
let partial = target_dir.join(format!("{}.partial", snapshots::dir_name(&image)));
|
||||
if partial.exists() {
|
||||
std::fs::remove_dir_all(&partial)?;
|
||||
}
|
||||
std::fs::create_dir_all(&partial)?;
|
||||
let mut folders = Vec::new();
|
||||
for folder in snapshots::folders_to_copy(&probe, &container_root) {
|
||||
progress(&format!("Copying {folder}"));
|
||||
let local = snapshots::local_folder(&partial, &folder);
|
||||
let Some(parent) = local.parent() else { continue };
|
||||
std::fs::create_dir_all(parent)?;
|
||||
// Into an existing folder: `docker cp` puts the source folder in it under its own name.
|
||||
let args = ["cp".into(), format!("{}:{folder}", container.name), format!("{}/", parent.to_string_lossy())];
|
||||
match ide_docker::cli(endpoint, None, &args, COPY_TIMEOUT, None) {
|
||||
Ok(_) => folders.push(folder),
|
||||
Err(e) => tracing::warn!("cannot copy {folder} from {}: {}", container.name, e.message),
|
||||
}
|
||||
}
|
||||
drop(container);
|
||||
progress("Preparing the language server's interpreter");
|
||||
let snapshot = Snapshot {
|
||||
neutral_python: snapshots::neutral_python(cache, &probe.version),
|
||||
image: image.clone(),
|
||||
probe,
|
||||
folders,
|
||||
};
|
||||
snapshots::write(&partial, &snapshot)?;
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
std::fs::rename(&partial, &dir)?;
|
||||
snapshots::make_current(target_dir, &image)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Distinguishes probe containers.
|
||||
fn unique() -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
format!("{:x}-{}-{}", secs & 0xff_ffff, std::process::id(), COUNT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
@@ -3047,7 +3047,16 @@ impl PluginWorkspace for HostWorkspace {
|
||||
&project.root,
|
||||
&project.settings.effective(),
|
||||
);
|
||||
if config.debug_adapter.is_none() {
|
||||
if config.debug_adapter.is_none()
|
||||
&& let Some(language) = config.extras.params.get(super::run_detectors::DEBUG_LANGUAGE)
|
||||
{
|
||||
config.debug_adapter = project
|
||||
.languages
|
||||
.get(&ide_core::LanguageId::new(language.as_str()))
|
||||
.and_then(|l| l.debugger)
|
||||
.map(|d| d.adapter);
|
||||
}
|
||||
if config.debug_adapter.is_none() && !config.extras.params.contains_key(super::run_detectors::DEBUG_ATTACH) {
|
||||
let cwd = config.cwd.as_ref().map(|p| p.as_ref()).unwrap_or(&project.root);
|
||||
config.debug_adapter =
|
||||
config.args.iter().map(|arg| cwd.join(arg)).filter(|path| path.is_file()).find_map(|path| {
|
||||
|
||||
@@ -77,9 +77,22 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest side of a plugin diff, in UTF-16 code units (earlier hosts allowed 1 MiB).
|
||||
pub(super) const MAX_DIFF_SIDE: usize = 8 << 20;
|
||||
|
||||
fn diff_side(value: &str, side: &str) -> Result<(), HostError> {
|
||||
if value.contains('\0') {
|
||||
return Err(HostError::Invalid(format!("the {side} side of the diff contains NUL")));
|
||||
}
|
||||
if value.encode_utf16().count() > MAX_DIFF_SIDE {
|
||||
return Err(HostError::Invalid(format!("diff too large: the {side} side is over {} MiB", MAX_DIFF_SIDE >> 20)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
diff_side(&diff.left, "left")?;
|
||||
diff_side(&diff.right, "right")?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
@@ -402,6 +415,67 @@ fn validate_configurations(
|
||||
if let Some(cwd) = &c.cwd {
|
||||
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
|
||||
}
|
||||
validate_env(&c.env)?;
|
||||
validate_debugger(c.debugger.as_ref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Like `process` commands, a configuration only adds variables: one the IDE's own
|
||||
/// environment has (`PATH`, …) would change what the approved program runs.
|
||||
fn validate_env(env: &[(String, String)]) -> Result<(), HostError> {
|
||||
if env.len() > 64 {
|
||||
return Err(HostError::Invalid("at most 64 environment variables".into()));
|
||||
}
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
for (name, value) in env {
|
||||
if name.is_empty() || name.len() > 256 || name.contains(['=', '\0']) {
|
||||
return Err(HostError::Invalid(format!("{name:?} is not an environment variable name")));
|
||||
}
|
||||
if value.len() > 4096 || value.contains('\0') {
|
||||
return Err(HostError::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
|
||||
}
|
||||
let key = if cfg!(windows) { name.to_ascii_uppercase() } else { name.clone() };
|
||||
if !names.insert(key) || inherited_env(name) {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"environment variable {name:?} is repeated or already set; only new variables may be added"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inherited_env(name: &str) -> bool {
|
||||
std::env::vars_os().any(|(inherited, _)| {
|
||||
if cfg!(windows) { inherited.to_string_lossy().eq_ignore_ascii_case(name) } else { inherited == name }
|
||||
})
|
||||
}
|
||||
|
||||
/// Attach to Process kinds a configuration may attach to.
|
||||
pub(super) const ATTACH_KINDS: [&str; 5] = ["dotnet", "node", "python", "go", "native"];
|
||||
|
||||
fn validate_debugger(debugger: Option<&ide_plugin::PluginDebugger>) -> Result<(), HostError> {
|
||||
match debugger {
|
||||
Some(ide_plugin::PluginDebugger::Launch { language }) => {
|
||||
if language.is_empty()
|
||||
|| language.len() > 64
|
||||
|| !language.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
{
|
||||
return Err(HostError::Invalid(format!("{language:?} is not a language id")));
|
||||
}
|
||||
}
|
||||
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
|
||||
if !ATTACH_KINDS.contains(&kind.as_str()) {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"attach-child kind {kind:?} is not one of {}",
|
||||
ATTACH_KINDS.join(", ")
|
||||
)));
|
||||
}
|
||||
if command_contains.as_ref().is_some_and(|t| t.is_empty() || t.len() > 256 || t.contains('\0')) {
|
||||
return Err(HostError::Invalid("attach-child command-contains must be 1-256 bytes".into()));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -518,6 +592,52 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// The process an `attach-child` configuration's debugger attaches to: the first Attach to
|
||||
/// Process candidate of its kind that `run` (a run of this configuration) started, waited
|
||||
/// for while the run lasts, up to a minute. Only the run's own descendants are candidates.
|
||||
pub fn debug_attach_target(
|
||||
&self,
|
||||
host: &crate::Host,
|
||||
client: &str,
|
||||
config: &str,
|
||||
run: ide_core::RunId,
|
||||
) -> IdeResult<ide_dap::attach::AttachableProcess> {
|
||||
use super::run_detectors::{DEBUG_ATTACH, DEBUG_ATTACH_CONTAINS};
|
||||
let Some((plugin, id)) = config.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':')) else {
|
||||
return Err(IdeError::invalid("not a plugin configuration"));
|
||||
};
|
||||
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
|
||||
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let kind = owned
|
||||
.extras
|
||||
.params
|
||||
.get(DEBUG_ATTACH)
|
||||
.filter(|k| ATTACH_KINDS.contains(&k.as_str()))
|
||||
.ok_or_else(|| IdeError::invalid("this configuration does not attach to a process"))?
|
||||
.clone();
|
||||
let contains = owned.extras.params.get(DEBUG_ATTACH_CONTAINS).cloned();
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let info = crate::ops::run::list(host).into_iter().find(|r| r.id == run && r.config_id == owned.id);
|
||||
let Some(pid) = info.as_ref().filter(|r| r.running).and_then(|r| r.pid) else {
|
||||
return Err(IdeError::not_found("the run ended before a process to debug started"));
|
||||
};
|
||||
let found = ide_dap::attach::attachable_descendants(pid)?.into_iter().find(|p| {
|
||||
serde_json::to_value(p.kind).ok().and_then(|v| v.as_str().map(|k| k == kind)) == Some(true)
|
||||
&& contains.as_ref().is_none_or(|t| p.command_line.contains(t.as_str()))
|
||||
});
|
||||
if let Some(process) = found {
|
||||
return Ok(process);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(IdeError::not_found(format!("no {kind} process started by the run within a minute")));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal launch metadata; it is ignored after the original operation finishes.
|
||||
pub(super) const RUN_OPERATION: &str = "plugin.operation";
|
||||
impl PluginHost {
|
||||
@@ -571,3 +691,73 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn diff(left: String, right: String) -> ide_plugin::ContentDiff {
|
||||
ide_plugin::ContentDiff { left, right, left_label: None, right_label: None, title: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_sides_up_to_the_limit_open_and_larger_ones_say_why() {
|
||||
assert!(validate_diff(&diff("x".repeat(5 << 20), "y".repeat(MAX_DIFF_SIDE))).is_ok());
|
||||
let err = validate_diff(&diff(String::new(), "y".repeat(MAX_DIFF_SIDE + 1))).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, HostError::Invalid(m) if m == "diff too large: the right side is over 8 MiB"),
|
||||
"{err:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(validate_diff(&diff("a\0".into(), String::new())), Err(HostError::Invalid(m)) if m.contains("NUL"))
|
||||
);
|
||||
}
|
||||
|
||||
fn launch(
|
||||
env: Vec<(&str, &str)>,
|
||||
debugger: Option<ide_plugin::PluginDebugger>,
|
||||
) -> ide_plugin::PluginRunConfiguration {
|
||||
ide_plugin::PluginRunConfiguration {
|
||||
id: "app".into(),
|
||||
name: "App".into(),
|
||||
program: "tool".into(),
|
||||
env: env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
|
||||
debugger,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurations_only_add_environment_variables() {
|
||||
let root = std::env::temp_dir();
|
||||
let ok = |c| validate_configurations(&root, &[c]).is_ok();
|
||||
assert!(ok(launch(vec![("OPENIDEA_TEST_NEW_VARIABLE", "1")], None)));
|
||||
let (inherited, _) = std::env::vars().next().expect("the test runs with an environment");
|
||||
assert!(!ok(launch(vec![(inherited.as_str(), "x")], None)));
|
||||
assert!(!ok(launch(vec![("A_B", "1"), ("A_B", "2")], None)));
|
||||
for name in ["", "A=B", "A\0"] {
|
||||
assert!(!ok(launch(vec![(name, "1")], None)), "{name:?}");
|
||||
}
|
||||
assert!(!ok(launch(vec![("OPENIDEA_TEST_NUL", "a\0")], None)));
|
||||
let many: Vec<_> = (0..65).map(|i| (format!("OPENIDEA_TEST_{i}"), String::new())).collect();
|
||||
let mut config = launch(vec![], None);
|
||||
config.env = many;
|
||||
assert!(!ok(config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurations_name_a_language_or_an_attach_to_process_kind() {
|
||||
use ide_plugin::PluginDebugger::{AttachChild, Launch};
|
||||
let root = std::env::temp_dir();
|
||||
let ok = |d| validate_configurations(&root, &[launch(vec![], Some(d))]).is_ok();
|
||||
assert!(ok(Launch { language: "csharp".into() }));
|
||||
assert!(!ok(Launch { language: String::new() }));
|
||||
assert!(!ok(Launch { language: "c sharp".into() }));
|
||||
for kind in ATTACH_KINDS {
|
||||
assert!(ok(AttachChild { kind: kind.into(), command_contains: None }), "{kind}");
|
||||
}
|
||||
assert!(!ok(AttachChild { kind: "java".into(), command_contains: None }));
|
||||
assert!(ok(AttachChild { kind: "dotnet".into(), command_contains: Some("Worker.dll".into()) }));
|
||||
assert!(!ok(AttachChild { kind: "dotnet".into(), command_contains: Some(String::new()) }));
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ pub const COMMANDS: &[&str] = &[
|
||||
"plugin_terminal_session_close",
|
||||
"plugin_terminal_session_spawn",
|
||||
"plugin_prepare_debug_run",
|
||||
"plugin_debug_attach_target",
|
||||
"plugin_finish_operation",
|
||||
"plugin_content_tab_action",
|
||||
"plugin_post_events",
|
||||
@@ -572,6 +573,15 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_debug_attach_target", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let (config, run) = (c.arg::<String>("config")?, c.arg("run")?);
|
||||
blocking(move || plugins.debug_attach_target(&c.host, &c.window, &config, run)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_finish_operation", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
|
||||
@@ -130,6 +130,11 @@ pub struct PluginRunDetection {
|
||||
pub(crate) project_pins: super::api::ProjectPins,
|
||||
}
|
||||
|
||||
/// `RunConfig.extras.params` keys carrying an API 1.9 configuration's debugger.
|
||||
pub const DEBUG_LANGUAGE: &str = "debugLanguage";
|
||||
pub const DEBUG_ATTACH: &str = "debugAttach";
|
||||
pub const DEBUG_ATTACH_CONTAINS: &str = "debugAttachContains";
|
||||
|
||||
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
|
||||
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
|
||||
}
|
||||
@@ -195,6 +200,19 @@ impl ExternalDetection for PluginRunDetection {
|
||||
if let Some(framework) = &c.framework {
|
||||
config.extras.params.insert("framework".into(), framework.clone());
|
||||
}
|
||||
config.env = c.env.iter().cloned().collect();
|
||||
match &c.debugger {
|
||||
Some(ide_plugin::PluginDebugger::Launch { language }) => {
|
||||
config.extras.params.insert(DEBUG_LANGUAGE.into(), language.clone());
|
||||
}
|
||||
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
|
||||
config.extras.params.insert(DEBUG_ATTACH.into(), kind.clone());
|
||||
if let Some(text) = command_contains {
|
||||
config.extras.params.insert(DEBUG_ATTACH_CONTAINS.into(), text.clone());
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ impl Host {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Untitled buffers belong to the window, not a project: they follow it
|
||||
// into the project's fresh document registry.
|
||||
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
@@ -174,6 +177,7 @@ impl Host {
|
||||
let root = member.as_ref().unwrap_or(window_root);
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
crate::ops::sdk::container_python::configure_server(cfg, &data_dir, root, &effective);
|
||||
if cfg.name == "jdtls" {
|
||||
// A workspace member keeps its build model under its own key (Build Project).
|
||||
let cache = match &member {
|
||||
@@ -620,6 +624,9 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
@@ -630,6 +637,7 @@ impl Host {
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
crate::ops::sdk::container_python::ensure(self, &project);
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
@@ -701,12 +709,29 @@ impl Host {
|
||||
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
|
||||
}
|
||||
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
|
||||
let watch = dir.clone();
|
||||
self.open_light_in(window, dir, Some(&watch))
|
||||
}
|
||||
|
||||
/// LightEdit without a file (a welcome window's File › New File): rooted
|
||||
/// at the user's home folder, watching nothing until a buffer is saved
|
||||
/// into a real folder ([`Project::watch_light_dir`]).
|
||||
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
let dir = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
|
||||
self.open_light_in(window, dir, None)
|
||||
}
|
||||
|
||||
fn open_light_in(&self, window: &str, dir: PathBuf, watch_dir: Option<&Path>) -> IdeResult<Arc<Project>> {
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Untitled buffers belong to the window: they follow it out of a project.
|
||||
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.light
|
||||
{
|
||||
current.watch_light_dir(&dir);
|
||||
if let Some(dir) = watch_dir {
|
||||
current.watch_light_dir(dir);
|
||||
}
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
@@ -725,7 +750,16 @@ impl Host {
|
||||
let syntax = Arc::new(SyntaxCache::new(self.syntax()));
|
||||
documents.add_observer(Arc::new(SyntaxObserver(syntax.clone())));
|
||||
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
|
||||
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
|
||||
let watcher = match watch_dir {
|
||||
Some(dir) => light_watcher(dir, &sink, &documents, &history)?,
|
||||
// A quiet private folder stands in until the first save brings a
|
||||
// real folder in.
|
||||
None => {
|
||||
let idle = self.local_data_dir.join("light-edit");
|
||||
std::fs::create_dir_all(&idle)?;
|
||||
light_watcher(&idle, &sink, &documents, &history)?
|
||||
}
|
||||
};
|
||||
let project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
@@ -771,8 +805,11 @@ impl Host {
|
||||
light: true,
|
||||
root: dir,
|
||||
});
|
||||
tracing::info!("window {window} edits {} without a project", file.display());
|
||||
tracing::info!("window {window} edits {} without a project", project.root.display());
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
@@ -1456,7 +1493,7 @@ fn content_root_watcher(
|
||||
) -> Option<Watcher> {
|
||||
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
|
||||
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
|
||||
let root = dir.to_path_buf();
|
||||
let (root, events) = (dir.to_path_buf(), sink.clone());
|
||||
Watcher::start(
|
||||
dir,
|
||||
excludes.to_vec(),
|
||||
@@ -1478,6 +1515,11 @@ fn content_root_watcher(
|
||||
}
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
// A member outside the project folder: its repository changes (commit, checkout,
|
||||
// fetch, pull) reach the UI only through this watcher.
|
||||
if batch.vcs.iter().any(|p| vcs_path_changed(p)) {
|
||||
events.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
|
||||
|
||||
@@ -218,6 +218,13 @@ impl Dispatcher {
|
||||
})
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open_untitled", |c| async move {
|
||||
ops::doc::open_untitled(&c.host, &c.window, c.arg::<Option<String>>("name")?).await
|
||||
})
|
||||
.add(
|
||||
"doc_save_as",
|
||||
|c| async move { ops::doc::save_as(&c.host, &c.window, c.arg("doc")?, c.arg("path")?).await },
|
||||
)
|
||||
.add("doc_close", |c| async move {
|
||||
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
|
||||
})
|
||||
|
||||
@@ -289,6 +289,7 @@ impl crate::Host {
|
||||
}
|
||||
}
|
||||
project.lsp.reconfigure().await;
|
||||
crate::ops::sdk::container_python::ensure(self, &project);
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
|
||||
}
|
||||
Ok(())
|
||||
@@ -341,6 +342,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "clear_caches"
|
||||
| "clipboard_file_list"
|
||||
| "clipboard_has_files"
|
||||
| "clipboard_has_image"
|
||||
| "clipboard_image"
|
||||
| "clipboard_set_file_list"
|
||||
| "clipboard_write_text"
|
||||
@@ -400,6 +402,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "web_unresolved_references"
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "light_edit_open_untitled"
|
||||
| "workspace_create"
|
||||
| "workspace_discover_projects"
|
||||
| "workspace_update"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! A Compose run target as the Python interpreter, against a real daemon and the real
|
||||
//! basedpyright. Ignored by default: `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the
|
||||
//! daemon can bind-mount (on this machine, or at the same path in a VM such as OrbStack's),
|
||||
//! `docker` with the Compose plugin and `basedpyright-langserver` must be on `PATH`, and
|
||||
//! this machine's Python must not have Django installed.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::IdeEvent;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
type Events = Arc<Mutex<Vec<IdeEvent>>>;
|
||||
|
||||
fn host(base: &Path, events: &Events) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new({
|
||||
let events = events.clone();
|
||||
move |_| {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
|
||||
}
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// basedpyright's latest diagnostics of `path` once `done` accepts them.
|
||||
async fn diagnostics(events: &Events, path: &Path, timeout: Duration, done: impl Fn(&[String]) -> bool) -> Vec<String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let latest = events.lock().unwrap().iter().rev().find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path: p, items, source } if **p == *path && source.contains("basedpyright") => {
|
||||
Some(items.iter().map(|d| d.message.clone()).collect::<Vec<_>>())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(messages) = &latest
|
||||
&& done(messages)
|
||||
{
|
||||
return messages.clone();
|
||||
}
|
||||
assert!(Instant::now() < deadline, "timed out; last diagnostics: {latest:?}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn unresolved(messages: &[String]) -> bool {
|
||||
messages.iter().any(|m| m.contains("\"django") && m.contains("could not be resolved"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires a Docker daemon, basedpyright and OPENIDEA_DOCKER_TEST_DIR"]
|
||||
async fn compose_target_is_the_python_interpreter() {
|
||||
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let files = [
|
||||
("compose.yaml", "services:\n web:\n build: .\n volumes:\n - .:/app\n working_dir: /app\n"),
|
||||
(
|
||||
"Dockerfile",
|
||||
"FROM python:3.12-slim\nCOPY requirements.txt /tmp/requirements.txt\n\
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt\n",
|
||||
),
|
||||
("requirements.txt", "Django==5.1.3\n"),
|
||||
(
|
||||
"app/main.py",
|
||||
"import django\nfrom django.http import HttpResponse\n\nprint(django.get_version(), HttpResponse)\n",
|
||||
),
|
||||
];
|
||||
for (path, text) in files {
|
||||
std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(root.join(path), text).unwrap();
|
||||
}
|
||||
let main = root.join("app/main.py");
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let events: Events = Arc::default();
|
||||
let host = host(state.path(), &events);
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project(WINDOW, &root).unwrap();
|
||||
let set = |key: &str, value: serde_json::Value| {
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
WINDOW,
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
key,
|
||||
&value.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
set("runTargets", serde_json::json!([{ "kind": "compose", "id": "web", "name": "Compose web", "service": "web" }]));
|
||||
|
||||
// A local interpreter: Django is only in the image.
|
||||
ops::doc::open(&host, WINDOW, main.clone()).await.unwrap();
|
||||
let before = diagnostics(&events, &main, Duration::from_secs(120), unresolved).await;
|
||||
eprintln!("local interpreter: {before:?}");
|
||||
|
||||
// The run target as the interpreter: a snapshot is taken in the background, then the
|
||||
// server restarts with the copied site-packages as `extraPaths`.
|
||||
set("languages.python.interpreter", serde_json::json!("target:web"));
|
||||
let after = diagnostics(&events, &main, Duration::from_secs(900), |m| !unresolved(m)).await;
|
||||
eprintln!("run target interpreter: {after:?}");
|
||||
assert!(after.iter().all(|m| !m.contains("could not be resolved")), "{after:?}");
|
||||
|
||||
let info = ops::sdk::sdk_container_python(&host, WINDOW).unwrap().expect("a run target interpreter");
|
||||
eprintln!("snapshot: version {:?}, image {:?}, folders {:?}", info.version, info.image, info.folders);
|
||||
assert_eq!(info.name, "Compose web");
|
||||
assert!(info.version.as_deref().is_some_and(|v| v.starts_with("3.12.")), "{info:?}");
|
||||
assert!(info.packages.iter().any(|p| p.name == "Django" && p.version == "5.1.3"), "{:?}", info.packages);
|
||||
let site = info.folders.iter().find(|f| f.0.ends_with("site-packages")).expect("site-packages copied");
|
||||
assert!(site.0.join("django/__init__.py").is_file());
|
||||
assert!(site.0.starts_with(state.path().join("data/container-python")), "{site:?}");
|
||||
let servers = ops::lsp::lsp_servers(&host, WINDOW).unwrap();
|
||||
eprintln!("servers: {:?}", servers.iter().map(|s| (&s.name, s.status)).collect::<Vec<_>>());
|
||||
|
||||
// Run: a Python configuration without its own target runs in the container.
|
||||
let out = Arc::new(Mutex::new(String::new()));
|
||||
let sink = out.clone();
|
||||
let config = RunConfig {
|
||||
id: "main".into(),
|
||||
name: "main".into(),
|
||||
kind: "python".into(),
|
||||
command: "python3".into(),
|
||||
args: vec!["app/main.py".into()],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
ops::run::start(
|
||||
&host,
|
||||
WINDOW,
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().unwrap().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..1200 {
|
||||
if out.lock().unwrap().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let out = out.lock().unwrap().clone();
|
||||
eprintln!("run output: {out}");
|
||||
assert!(out.contains("5.1.3") && out.contains("exit code 0"), "{out}");
|
||||
|
||||
// Refresh takes a new snapshot of the same image; nothing is left running.
|
||||
let refreshed = ops::sdk::sdk_container_python_refresh(&host, WINDOW).await.unwrap().unwrap();
|
||||
assert_eq!(refreshed.image, info.image);
|
||||
let probes = Command::new("docker")
|
||||
.args(["ps", "--all", "--quiet", "--filter", "label=openidea.python-probe=true"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&probes.stdout).trim(), "", "probe containers are removed");
|
||||
Command::new("docker").args(["compose", "down", "--rmi", "local"]).current_dir(&root).status().unwrap();
|
||||
}
|
||||
@@ -179,3 +179,73 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
|
||||
assert!(!bin.join("python.probed").exists());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn untitled_buffers_reach_the_disk_through_save_as() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let dir = tmp_root.join("notes");
|
||||
let host = host(&tmp_root.join("app"));
|
||||
|
||||
// A window with no file (a welcome screen's File › New File): rooted at the
|
||||
// home folder, no watcher yet, doc commands work.
|
||||
let empty = host.open_light_empty("main").unwrap();
|
||||
assert!(empty.light);
|
||||
let untitled = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
assert_eq!(untitled.path, std::path::PathBuf::from("untitled:Untitled-1"));
|
||||
assert_eq!(untitled.language.as_str(), "plaintext");
|
||||
let version = ops::doc::edit(&host, "main", untitled.id, untitled.version, untitled_edit("# notes\n")).unwrap();
|
||||
assert!(ops::doc::save(&host, "main", untitled.id).await.is_ok(), "saving nowhere is a no-op");
|
||||
assert!(host.project("main").unwrap().documents.is_dirty(untitled.id).unwrap());
|
||||
|
||||
// Save As: the buffer lands on the disk and turns into a file document.
|
||||
let target = dir.join("deep/notes.md");
|
||||
let saved = ops::doc::save_as(&host, "main", untitled.id, target.clone()).await.unwrap();
|
||||
assert_eq!(saved.path, target);
|
||||
assert_eq!(saved.language.as_str(), "markdown");
|
||||
assert!(!saved.dirty);
|
||||
// The platform's default line separator applies to new buffers.
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "# notes\n");
|
||||
ops::doc::edit(&host, "main", untitled.id, version, untitled_edit("x\n")).unwrap();
|
||||
ops::doc::save(&host, "main", untitled.id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "x\n# notes\n");
|
||||
|
||||
// The saved file's folder is followed for external changes now.
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
fn untitled_edit(insert: &str) -> ChangeSet {
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: insert.into() }] }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn untitled_buffers_follow_the_window_into_a_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = host(&tmp_root.join("app"));
|
||||
|
||||
// A LightEdit window with an edited untitled buffer (and a clean one).
|
||||
host.open_light_empty("main").unwrap();
|
||||
let draft = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
ops::doc::edit(&host, "main", draft.id, draft.version, untitled_edit("{\"draft\": true}")).unwrap();
|
||||
let clean = ops::doc::open_untitled(&host, "main", None).await.unwrap();
|
||||
|
||||
// The window switches to a project: the buffers follow, contents and dirty flags kept.
|
||||
let project_root = tmp_root.join("proj");
|
||||
std::fs::create_dir_all(&project_root).unwrap();
|
||||
let project = host.open_project("main", &project_root).unwrap();
|
||||
assert!(!project.light);
|
||||
|
||||
let carried =
|
||||
ops::doc::open_untitled(&host, "main", Some(draft.path.to_string_lossy().into_owned())).await.unwrap();
|
||||
assert_eq!(carried.path, draft.path, "same key, fresh registry: a new doc id carries the content");
|
||||
let snap = ops::doc::snapshot(&host, "main", carried.id).await.unwrap();
|
||||
assert_eq!(snap.text, "{\"draft\": true}");
|
||||
assert!(host.project("main").unwrap().documents.is_dirty(carried.id).unwrap());
|
||||
assert_eq!(snap.language.as_str(), "plaintext");
|
||||
|
||||
let clean_carried =
|
||||
ops::doc::open_untitled(&host, "main", Some(clean.path.to_string_lossy().into_owned())).await.unwrap();
|
||||
assert!(!host.project("main").unwrap().documents.is_dirty(clean_carried.id).unwrap());
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -110,6 +110,11 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
|
||||
let found = h.json("search_text", json!({"q":"needle","limit":2})).await;
|
||||
assert_eq!(found["items"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(found["more"], true);
|
||||
assert_eq!(found["nextOffset"], 2);
|
||||
// The next page goes on where this one ended.
|
||||
let every = h.json("search_text", json!({"q":"needle","limit":20})).await["items"].as_array().unwrap().clone();
|
||||
let next = h.json("search_text", json!({"q":"needle","limit":2,"offset":2})).await;
|
||||
assert_eq!(next["items"].as_array().unwrap()[..], every[2..every.len().min(4)]);
|
||||
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
|
||||
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
|
||||
// The matched line comes with the result (unsaved text included), and lines around it on request.
|
||||
@@ -162,7 +167,9 @@ async fn window_reports_supply_editor_selection_and_problems_then_disappear_on_c
|
||||
assert_eq!(focused["selection"]["startColumn"], 3);
|
||||
let problems = h.json("get_file_problems", json!({"filePath":"src/a.txt","errorsOnly":false})).await;
|
||||
assert_eq!(problems["errors"][0]["description"], "reported warning");
|
||||
assert_eq!(h.json("get_project_problems", json!({})).await["files"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(h.json("get_project_problems", json!({})).await["files"], json!([]), "errors only by default");
|
||||
let all = h.json("get_project_problems", json!({"errorsOnly":false})).await;
|
||||
assert_eq!((all["files"].as_array().unwrap().len(), &all["total"]), (1, &json!(1)));
|
||||
h.host.close_project("main");
|
||||
h.host.open_project("main", &h.root).unwrap();
|
||||
assert_eq!(h.json("get_all_open_file_paths", json!({})).await["openFiles"], json!([]));
|
||||
@@ -287,6 +294,41 @@ async fn execution_requires_a_window_and_denial_never_runs_a_build() {
|
||||
assert!(error.contains("window closed"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn run_configurations_are_listed_a_page_at_a_time_and_filtered() {
|
||||
let h = Harness::new();
|
||||
h.trust().await;
|
||||
let saved = |name: &str, command: &str, env: &[(&str, &str)]| ide_run::RunConfig {
|
||||
id: format!("user:{name}"),
|
||||
name: name.into(),
|
||||
kind: "shell".into(),
|
||||
command: command.into(),
|
||||
args: Vec::new(),
|
||||
cwd: None,
|
||||
env: env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let configs =
|
||||
[saved("build", "tsc", &[("MODE", "ci")]), saved("lint", "oxlint .", &[]), saved("test", "vitest", &[])];
|
||||
ide_run::save_user_configs(&h.root, &configs).unwrap();
|
||||
let all = h.json("get_run_configurations", json!({})).await;
|
||||
let total = all["total"].as_u64().unwrap();
|
||||
assert!(total >= 3, "{all}");
|
||||
// A saved configuration's own environment is listed.
|
||||
let build = all["configurations"].as_array().unwrap().iter().find(|c| c["name"] == "build").unwrap();
|
||||
assert_eq!(build["environment"], json!({ "MODE": "ci" }));
|
||||
let first = h.json("get_run_configurations", json!({"limit": 1})).await;
|
||||
assert_eq!((first["configurations"].as_array().unwrap().len(), &first["nextOffset"]), (1, &json!(1)));
|
||||
let lint = h.json("get_run_configurations", json!({"query": "OXLINT"})).await;
|
||||
let found = lint["configurations"].as_array().unwrap();
|
||||
assert!(!found.is_empty() && (found.len() as u64) < total, "{lint}");
|
||||
assert!(found.iter().all(|c| c.to_string().to_lowercase().contains("oxlint")), "{lint}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn git_status_and_diff_on_a_temporary_repository() {
|
||||
let h = Harness::new();
|
||||
@@ -308,6 +350,18 @@ async fn git_status_and_diff_on_a_temporary_repository() {
|
||||
std::fs::write(h.root.join("src/a.txt"), "changed\n").unwrap();
|
||||
let status = h.json("git_status", json!({"repositoryPathRelativeToProject":"."})).await;
|
||||
assert_eq!(status["repositories"][0]["unstagedCount"], 1);
|
||||
// Two entries, one per page.
|
||||
std::fs::write(h.root.join("src/new.txt"), "new\n").unwrap();
|
||||
let page =
|
||||
|offset: u64| h.json("git_status", json!({"repositoryPathRelativeToProject":".","limit":1,"offset":offset}));
|
||||
let first = page(0).await["repositories"][0].clone();
|
||||
assert_eq!(
|
||||
(&first["totalEntries"], &first["hasMoreEntries"], &first["nextOffset"]),
|
||||
(&json!(2), &json!(true), &json!(1))
|
||||
);
|
||||
let second = page(1).await["repositories"][0].clone();
|
||||
assert_ne!(second["entries"][0], first["entries"][0]);
|
||||
assert!(second.get("nextOffset").is_none(), "{second}");
|
||||
assert!(h.call("git_diff", json!({"filePath":"src/a.txt"})).await.contains("+changed"));
|
||||
assert!(h.call("git_diff", json!({})).await.contains("src/a.txt"));
|
||||
assert!(h.call("git_diff", json!({"staged":true})).await.is_empty());
|
||||
@@ -610,6 +664,19 @@ async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
|
||||
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
|
||||
let runs = h.json("list_runs", json!({})).await;
|
||||
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
|
||||
// Newest first, brief, filtered by state and paged.
|
||||
let newest = h.json("list_runs", json!({"limit": 1})).await;
|
||||
assert_eq!(
|
||||
(&newest["runs"][0]["name"], &newest["total"], &newest["nextOffset"]),
|
||||
(&json!("_agent_server"), &json!(2), &json!(1))
|
||||
);
|
||||
assert!(newest["runs"][0].get("outputLength").is_none(), "{newest}");
|
||||
assert_eq!(h.json("list_runs", json!({"state": "idle"})).await["total"], 2);
|
||||
assert_eq!(h.json("list_runs", json!({"state": "running"})).await["total"], 0);
|
||||
assert_eq!(h.json("list_runs", json!({"state": "stoppedByAgent"})).await["total"], 2);
|
||||
assert_eq!(h.json("list_runs", json!({"state": "error"})).await["total"], 0);
|
||||
let unknown = h.error("list_runs", json!({"state": "done"})).await;
|
||||
assert!(unknown.contains("unknown state `done`"), "{unknown}");
|
||||
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
|
||||
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
|
||||
|
||||
|
||||
@@ -6450,7 +6450,13 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
|
||||
let configs = f.host.run_detection().detect(&f.project);
|
||||
assert!(configs.iter().any(|c| c.id == "plugin:execution:dynamic"
|
||||
&& c.is_test
|
||||
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")));
|
||||
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")
|
||||
&& c.env.get("OPENIDEA_EXECUTION_FIXTURE").map(String::as_str) == Some("1")
|
||||
&& c.extras.params.get("debugLanguage").map(String::as_str) == Some("python")));
|
||||
let worker = configs.iter().find(|c| c.id == "plugin:execution:worker").unwrap();
|
||||
assert_eq!(worker.extras.params.get("debugAttach").map(String::as_str), Some("dotnet"));
|
||||
assert_eq!(worker.extras.params.get("debugAttachContains").map(String::as_str), Some("Worker.dll"));
|
||||
assert!(worker.env.is_empty() && !worker.is_test);
|
||||
assert_eq!(
|
||||
f.host.parse_test_results("main", "plugin:execution:dynamic", "batch".into()).unwrap().unwrap().len(),
|
||||
3
|
||||
|
||||
@@ -46,6 +46,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"clear_caches",
|
||||
"clipboard_file_list",
|
||||
"clipboard_has_files",
|
||||
"clipboard_has_image",
|
||||
"clipboard_image",
|
||||
"clipboard_set_file_list",
|
||||
"clipboard_write_text",
|
||||
|
||||
@@ -207,3 +207,22 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
assert!(f.status().await.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn checkout_reports_a_branch_held_by_another_worktree() {
|
||||
let f = Flow::new("rust-app").await;
|
||||
let linked = f._tmp.path().join("linked");
|
||||
git(&f.root, &["worktree", "add", "-q", "-b", "topic", linked.to_str().unwrap()]);
|
||||
|
||||
let outcome = ops::vcs::vcs_checkout(&f.host, WINDOW, None, "topic".into(), false, None).await.unwrap();
|
||||
assert_eq!(
|
||||
outcome,
|
||||
ide_vcs::CheckoutOutcome::CheckedOutInWorktree {
|
||||
branch: "topic".into(),
|
||||
path: dunce::canonicalize(&linked).unwrap()
|
||||
}
|
||||
);
|
||||
// Nothing changed: the project is still on main.
|
||||
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
|
||||
assert!(branches.iter().any(|b| b.name == "main" && b.current), "{branches:?}");
|
||||
}
|
||||
@@ -599,6 +599,28 @@ impl Store {
|
||||
txn.commit().map_err(err)
|
||||
}
|
||||
|
||||
/// [`Self::apply`] for records already serialized (by the parse pool).
|
||||
pub(crate) fn apply_raw(&self, puts: &[(String, Vec<u8>)], deletes: &[String], durable: bool) -> Result<()> {
|
||||
self.ensure_db()?;
|
||||
let db = self.db.read();
|
||||
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
|
||||
if !durable {
|
||||
txn.set_durability(Durability::None).map_err(err)?;
|
||||
}
|
||||
{
|
||||
let mut table = txn.open_table(FILES).map_err(err)?;
|
||||
for (rel, bytes) in puts {
|
||||
table.insert(rel.as_str(), bytes.as_slice()).map_err(err)?;
|
||||
}
|
||||
for rel in deletes {
|
||||
table.remove(rel.as_str()).map_err(err)?;
|
||||
}
|
||||
}
|
||||
// Any file change makes the columnar start-up snapshot stale.
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
txn.commit().map_err(err)
|
||||
}
|
||||
|
||||
/// Reclaims free pages in the database file; `true` if anything was compacted.
|
||||
pub(crate) fn compact(&self) -> Result<bool> {
|
||||
self.ensure_db()?;
|
||||
|
||||
@@ -17,6 +17,8 @@ use std::time::{Duration, Instant, SystemTime};
|
||||
/// Files larger than this are listed but not parsed for symbols.
|
||||
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
|
||||
const CHUNK: usize = 512;
|
||||
/// Insert/persist batch of the initial scan's streaming pass.
|
||||
const SCAN_BATCH: usize = 2048;
|
||||
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
|
||||
#[cfg(not(test))]
|
||||
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
|
||||
@@ -436,10 +438,13 @@ impl Shared {
|
||||
});
|
||||
|
||||
let filter = self.filter();
|
||||
let walk_started = Instant::now();
|
||||
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
|
||||
let walk = walk_started.elapsed();
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
let classify_started = Instant::now();
|
||||
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
|
||||
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
|
||||
let d = self.data.read();
|
||||
@@ -452,6 +457,7 @@ impl Shared {
|
||||
.collect();
|
||||
(todo, stale)
|
||||
};
|
||||
let classify = classify_started.elapsed();
|
||||
if !stale.is_empty() {
|
||||
let mut d = self.data.write();
|
||||
for path in &stale {
|
||||
@@ -462,33 +468,76 @@ impl Shared {
|
||||
self.persist(&[], &stale, false);
|
||||
|
||||
let total = todo.len();
|
||||
let mut done = 0usize;
|
||||
let mut last_progress = Instant::now();
|
||||
for chunk in todo.chunks(CHUNK) {
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
self.index_files(chunk.iter().copied(), false);
|
||||
done += chunk.len();
|
||||
if last_progress.elapsed() >= PROGRESS_EVERY {
|
||||
last_progress = Instant::now();
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: "Indexing".into(),
|
||||
message: Some(format!("{done} / {total} files")),
|
||||
fraction: Some(done as f32 / total.max(1) as f32),
|
||||
done: false,
|
||||
cancellable: false,
|
||||
let mut laps = ChunkLaps::default();
|
||||
{
|
||||
// One streaming parallel pass: parse on the rayon pool without
|
||||
// per-chunk joins (a straggler file at a join's tail idles every
|
||||
// other core), while parsed files flow to this thread to be
|
||||
// inserted and persisted in batches. `filter` moves into the
|
||||
// parse thread; this thread keeps the outer one.
|
||||
let filter = self.filter();
|
||||
// Bounded: when inserting and persisting fall behind, the parse waits instead
|
||||
// of holding the whole project's records (and their bytes) in the channel.
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<ParsedFile>(2 * SCAN_BATCH);
|
||||
let cancelled = &self.cancelled;
|
||||
let mut done = 0usize;
|
||||
let mut last_progress = Instant::now();
|
||||
let pass_started = Instant::now();
|
||||
std::thread::scope(|scope| -> () {
|
||||
scope.spawn(move || {
|
||||
todo.par_iter().for_each(|f| {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let Some(out) = self.parse_found(f, &filter, false) else { return };
|
||||
let _ = tx.send(out);
|
||||
});
|
||||
// `tx` drops here: the consumer's loop ends with the channel.
|
||||
});
|
||||
}
|
||||
let mut batch: Vec<ParsedFile> = Vec::with_capacity(SCAN_BATCH);
|
||||
for out in rx {
|
||||
if self.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
batch.push(out);
|
||||
if batch.len() < SCAN_BATCH {
|
||||
continue;
|
||||
}
|
||||
done += batch.len();
|
||||
laps.add(self.publish(batch.drain(..)));
|
||||
if last_progress.elapsed() >= PROGRESS_EVERY {
|
||||
last_progress = Instant::now();
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: "Indexing".into(),
|
||||
message: Some(format!("{done} / {total} files")),
|
||||
fraction: Some(done as f32 / total.max(1) as f32),
|
||||
done: false,
|
||||
cancellable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() && !self.is_cancelled() {
|
||||
done += batch.len();
|
||||
laps.add(self.publish(batch.drain(..)));
|
||||
}
|
||||
});
|
||||
laps.extract = pass_started.elapsed();
|
||||
}
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
let durable_started = Instant::now();
|
||||
self.persist(&[], &[], true);
|
||||
let durable = durable_started.elapsed();
|
||||
if let Some(store) = &self.store {
|
||||
let recent: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
|
||||
let d = self.data.read();
|
||||
let snapshot_started = Instant::now();
|
||||
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
|
||||
tracing::warn!("index snapshot: {e}");
|
||||
}
|
||||
tracing::info!("index snapshot: written in {:?}", snapshot_started.elapsed());
|
||||
if let Err(e) = store.save_recent_file(&recent) {
|
||||
tracing::warn!("index recent snapshot: {e}");
|
||||
}
|
||||
@@ -506,54 +555,85 @@ impl Shared {
|
||||
self.emit_state();
|
||||
let d = self.data.read();
|
||||
tracing::info!(
|
||||
"indexed {} files ({} parsed, {} symbols) in {:?}",
|
||||
"indexed {} files ({} parsed, {} symbols) in {:?} (walk {:?}, classify {:?}, extract {:?}, insert {:?}, persist {:?}, durable {:?})",
|
||||
d.file_count,
|
||||
total,
|
||||
d.symbol_count,
|
||||
started.elapsed()
|
||||
started.elapsed(),
|
||||
walk,
|
||||
classify,
|
||||
laps.extract,
|
||||
laps.insert,
|
||||
laps.persist,
|
||||
durable
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts records in parallel, then publishes them to memory and disk.
|
||||
/// `force` re-parses files whose content is unchanged (their language changed).
|
||||
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) {
|
||||
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) -> ChunkLaps {
|
||||
let mut laps = ChunkLaps::default();
|
||||
let files: Vec<&Found> = files.collect();
|
||||
let filter = self.filter();
|
||||
let extracted: Vec<(String, FileRecord, PathBuf)> = files
|
||||
.par_iter()
|
||||
.filter_map(|f| {
|
||||
if self.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let rel = filter.rel(&f.path)?;
|
||||
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
|
||||
match self.extract(f, prev_hash) {
|
||||
Extracted::Unchanged => {
|
||||
// Same content, new mtime: keep the symbols, refresh metadata.
|
||||
let mut rec = self.data.read().record_of(&f.path)?;
|
||||
rec.mtime = f.mtime;
|
||||
rec.size = f.size;
|
||||
rec.ignored = f.ignored;
|
||||
Some((rel, rec, f.path.clone()))
|
||||
}
|
||||
Extracted::New(rec) => Some((rel, rec, f.path.clone())),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let started = Instant::now();
|
||||
let extracted: Vec<ParsedFile> = files.par_iter().filter_map(|f| self.parse_found(f, &filter, force)).collect();
|
||||
laps.extract = started.elapsed();
|
||||
if extracted.is_empty() {
|
||||
return;
|
||||
return laps;
|
||||
}
|
||||
laps.add(self.publish(extracted.into_iter()));
|
||||
laps
|
||||
}
|
||||
|
||||
/// Reads and parses one file (parallel-safe), serializing its record for
|
||||
/// the cache alongside the parse. `None` when the file is outside the
|
||||
/// scope or the scan was cancelled.
|
||||
fn parse_found(&self, f: &Found, filter: &std::sync::Arc<PathFilter>, force: bool) -> Option<ParsedFile> {
|
||||
if self.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let rel = filter.rel(&f.path)?;
|
||||
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
|
||||
let rec = match self.extract(f, prev_hash) {
|
||||
Extracted::Unchanged => {
|
||||
// Same content, new mtime: keep the symbols, refresh metadata.
|
||||
let mut rec = self.data.read().record_of(&f.path)?;
|
||||
rec.mtime = f.mtime;
|
||||
rec.size = f.size;
|
||||
rec.ignored = f.ignored;
|
||||
rec
|
||||
}
|
||||
Extracted::New(rec) => rec,
|
||||
};
|
||||
let key = filter.store_key(&f.path);
|
||||
let bytes = postcard::to_allocvec(&rec).ok();
|
||||
Some(ParsedFile { rel, rec, key, bytes, path: f.path.clone() })
|
||||
}
|
||||
|
||||
/// Inserts a parsed batch into the in-memory index and persists the records.
|
||||
fn publish(&self, batch: impl Iterator<Item = ParsedFile>) -> ChunkLaps {
|
||||
let mut laps = ChunkLaps::default();
|
||||
let started = Instant::now();
|
||||
let mut entries: Vec<(FileEntry, Vec<StoredSymbol>)> = Vec::new();
|
||||
let mut puts: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for out in batch {
|
||||
let ParsedFile { rel, rec, key, bytes, path } = out;
|
||||
if let (Some(key), Some(bytes)) = (key, bytes) {
|
||||
puts.push((key, bytes));
|
||||
}
|
||||
entries.push(FileEntry::new(path, rel, rec));
|
||||
}
|
||||
let puts: Vec<(String, FileRecord)> =
|
||||
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
|
||||
{
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
|
||||
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
|
||||
let mut d = self.data.write();
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
}
|
||||
self.persist(&puts, &[], false);
|
||||
laps.insert = started.elapsed();
|
||||
let started = Instant::now();
|
||||
self.persist_raw(&puts, &[], false);
|
||||
laps.persist = started.elapsed();
|
||||
laps
|
||||
}
|
||||
|
||||
fn schedule_snapshot(&self) {
|
||||
@@ -594,6 +674,15 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::persist`] for records already serialized by the parse pool.
|
||||
fn persist_raw(&self, puts: &[(String, Vec<u8>)], deletes: &[String], durable: bool) {
|
||||
if let Some(store) = &self.store
|
||||
&& let Err(e) = store.apply_raw(puts, deletes, durable)
|
||||
{
|
||||
tracing::warn!("index cache write failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted {
|
||||
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ignored: f.ignored, ..Default::default() };
|
||||
if f.ignored || f.size > MAX_SYMBOL_FILE {
|
||||
@@ -793,6 +882,33 @@ enum Extracted {
|
||||
New(FileRecord),
|
||||
}
|
||||
|
||||
/// Wall-time laps of one `index_files` chunk, accumulated by the scan.
|
||||
#[derive(Default)]
|
||||
struct ChunkLaps {
|
||||
extract: Duration,
|
||||
insert: Duration,
|
||||
persist: Duration,
|
||||
}
|
||||
|
||||
impl ChunkLaps {
|
||||
fn add(&mut self, other: ChunkLaps) {
|
||||
self.extract += other.extract;
|
||||
self.insert += other.insert;
|
||||
self.persist += other.persist;
|
||||
}
|
||||
}
|
||||
|
||||
/// One file parsed off the index thread: its record ready to publish, already
|
||||
/// serialized for the cache (in parallel with the parse).
|
||||
struct ParsedFile {
|
||||
rel: String,
|
||||
rec: FileRecord,
|
||||
/// Cache key and postcard bytes; `None` when outside the persisted root.
|
||||
key: Option<String>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::data::safe_rel;
|
||||
|
||||
@@ -80,18 +80,47 @@ pub fn trim_line_end(line: &[u8]) -> &[u8] {
|
||||
&line[..end]
|
||||
}
|
||||
|
||||
/// The longest line kept in full; the rest of a longer one is dropped (a server that writes a
|
||||
/// progress bar with `\r` only, or never a newline, must not grow memory without bound).
|
||||
pub const MAX_LINE: usize = 8 * 1024;
|
||||
|
||||
/// Reads `stream` to its end, calling `on_line` with each line decoded by [`decode`].
|
||||
/// Unlike `AsyncBufReadExt::lines`, a line that is not UTF-8 does not end the stream.
|
||||
/// A line longer than [`MAX_LINE`] bytes is cut there and ends with `…`.
|
||||
pub async fn read_lines<R: AsyncRead + Unpin>(stream: R, mut on_line: impl FnMut(String)) {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = Vec::new();
|
||||
let mut cut = false;
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_until(b'\n', &mut line).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => on_line(decode(trim_line_end(&line))),
|
||||
let (used, complete) = match reader.fill_buf().await {
|
||||
Ok([]) | Err(_) => break,
|
||||
Ok(buf) => {
|
||||
let end = buf.iter().position(|&b| b == b'\n');
|
||||
let piece = &buf[..end.unwrap_or(buf.len())];
|
||||
let room = MAX_LINE.saturating_sub(line.len());
|
||||
cut |= piece.len() > room;
|
||||
line.extend_from_slice(&piece[..piece.len().min(room)]);
|
||||
(end.map_or(buf.len(), |i| i + 1), end.is_some())
|
||||
}
|
||||
};
|
||||
reader.consume(used);
|
||||
if complete {
|
||||
emit(&mut line, &mut cut, &mut on_line);
|
||||
}
|
||||
}
|
||||
if !line.is_empty() || cut {
|
||||
emit(&mut line, &mut cut, &mut on_line);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(line: &mut Vec<u8>, cut: &mut bool, on_line: &mut impl FnMut(String)) {
|
||||
let mut text = decode(trim_line_end(line));
|
||||
if *cut {
|
||||
text.push('…');
|
||||
}
|
||||
line.clear();
|
||||
*cut = false;
|
||||
on_line(text);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -133,4 +162,19 @@ mod tests {
|
||||
assert!(lines[1].ends_with(" broken"));
|
||||
assert_eq!(lines[2], "last");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_very_long_line_is_cut_and_the_next_one_is_intact() {
|
||||
let mut input = vec![b'x'; MAX_LINE * 10 + 3];
|
||||
input.extend_from_slice(b"\nnext\r\n");
|
||||
// No newline at all after this one: still bounded.
|
||||
input.extend_from_slice(&vec![b'y'; MAX_LINE * 3]);
|
||||
let mut lines = Vec::new();
|
||||
read_lines(&input[..], |l| lines.push(l)).await;
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0].chars().count(), MAX_LINE + 1);
|
||||
assert!(lines[0].ends_with('…') && lines[0].starts_with("xxx"));
|
||||
assert_eq!(lines[1], "next");
|
||||
assert_eq!(lines[2].chars().count(), MAX_LINE + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
; Shell scripts: drawn over the stream mode, which knows only fixed word lists.
|
||||
; The last matching pattern wins.
|
||||
|
||||
[
|
||||
(string)
|
||||
(raw_string)
|
||||
(ansi_c_string)
|
||||
(translated_string)
|
||||
(heredoc_body)
|
||||
(heredoc_start)
|
||||
] @string
|
||||
|
||||
[
|
||||
(command_substitution)
|
||||
(process_substitution)
|
||||
(expansion)
|
||||
(simple_expansion)
|
||||
] @embedded
|
||||
|
||||
(comment) @comment
|
||||
|
||||
[
|
||||
"case"
|
||||
"do"
|
||||
"done"
|
||||
"elif"
|
||||
"else"
|
||||
"esac"
|
||||
"fi"
|
||||
"for"
|
||||
"function"
|
||||
"if"
|
||||
"in"
|
||||
"select"
|
||||
"then"
|
||||
"until"
|
||||
"while"
|
||||
"declare"
|
||||
"export"
|
||||
"local"
|
||||
"readonly"
|
||||
"typeset"
|
||||
"unset"
|
||||
"unsetenv"
|
||||
] @keyword
|
||||
|
||||
(command_name) @function.command
|
||||
(function_definition name: (word) @function.definition)
|
||||
|
||||
(variable_name) @variable
|
||||
(special_variable_name) @variable
|
||||
|
||||
[
|
||||
(number)
|
||||
(file_descriptor)
|
||||
] @number
|
||||
@@ -28,6 +28,7 @@ pub use syntax::{
|
||||
VueComponentImport, VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility,
|
||||
set_external_syntax, shift_pos, shift_range,
|
||||
};
|
||||
pub use syntax::{MethodBody, PythonClassFacts, PythonFacts, PythonMethodFacts};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -198,6 +199,10 @@ pub struct LanguageServerConfig {
|
||||
/// it parses a document.
|
||||
#[serde(default)]
|
||||
pub resync_on_configuration: bool,
|
||||
/// Set by the settings hook: what the user sees as the server when another program serves
|
||||
/// in its place (TypeScript 7's own `tsc --lsp` for vtsls).
|
||||
#[serde(skip)]
|
||||
pub display_name: Option<String>,
|
||||
/// Set on plugin languages: start exactly this approved program.
|
||||
#[serde(skip)]
|
||||
pub pinned: Option<ProgramPin>,
|
||||
|
||||
@@ -314,7 +314,7 @@ pub(crate) static SPECS: &[Spec] = &[
|
||||
name: "bash",
|
||||
language: tree_sitter_bash::LANGUAGE,
|
||||
outline: outline!("bash"),
|
||||
highlights: None,
|
||||
highlights: Some(Highlights { query: highlights!("bash"), first_wins: false }),
|
||||
fold_nodes: &["do_group", "if_statement", "case_statement", "heredoc_body"],
|
||||
fold_bodies: NONE,
|
||||
imports: NONE,
|
||||
|
||||
@@ -43,11 +43,13 @@ pub enum SyntaxHighlightKind {
|
||||
Operator,
|
||||
Punctuation,
|
||||
Parameter,
|
||||
/// A shell command name (`pgrep`, a script's own function).
|
||||
Command,
|
||||
}
|
||||
|
||||
impl SyntaxHighlightKind {
|
||||
/// Every kind, in `as u32` order: the legend for [`SyntaxHighlights::data`].
|
||||
pub const ALL: [Self; 25] = [
|
||||
pub const ALL: [Self; 26] = [
|
||||
Self::Keyword,
|
||||
Self::String,
|
||||
Self::StringSpecial,
|
||||
@@ -73,6 +75,7 @@ impl SyntaxHighlightKind {
|
||||
Self::Operator,
|
||||
Self::Punctuation,
|
||||
Self::Parameter,
|
||||
Self::Command,
|
||||
];
|
||||
|
||||
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
|
||||
@@ -153,6 +156,7 @@ const CAPTURES: &[(&str, Paint)] = {
|
||||
("function.builtin", Kind(K::Builtin)),
|
||||
("function.macro", Kind(K::Macro)),
|
||||
("function.definition", Kind(K::Function)),
|
||||
("function.command", Kind(K::Command)),
|
||||
("function", Kind(K::FunctionCall)),
|
||||
("method", Kind(K::FunctionCall)),
|
||||
("constructor", Kind(K::Type)),
|
||||
@@ -408,6 +412,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_scripts_highlight_declarations_commands_and_function_names() {
|
||||
use SyntaxHighlightKind as K;
|
||||
let src = "this::check_process() {\n local process\n process=\"${1:?}\"\n if [ -x \"$(command -v pgrep)\" ]; then\n if pgrep -f -q \"${process}\"; then\n functions::exit 1 \"'${process}' is running\"\n fi\n fi\n}\n# done\n";
|
||||
let got = spans("shellscript", src);
|
||||
for (text, expected) in [
|
||||
("this::check_process", K::Function),
|
||||
("local", K::Keyword),
|
||||
("if", K::Keyword),
|
||||
("command", K::Command),
|
||||
("pgrep", K::Command),
|
||||
("functions::exit", K::Command),
|
||||
("process", K::Variable),
|
||||
("# done", K::Comment),
|
||||
] {
|
||||
assert!(
|
||||
got.iter().any(|(word, kind)| word == text && *kind == expected),
|
||||
"missing {text}: {expected:?} in {got:?}"
|
||||
);
|
||||
}
|
||||
// An expansion inside a string is not string-coloured.
|
||||
assert!(!got.iter().any(|(word, kind)| word.contains("${process}") && *kind == K::String), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_kotlin_parameters_keep_variable_highlighting() {
|
||||
for (lang, src) in [
|
||||
|
||||
@@ -43,7 +43,7 @@ fn extract(tree: &Tree, text: &str) -> Vec<ClassBases> {
|
||||
// Selection-range start of the class: its name identifier (`class Foo`
|
||||
// -> `Foo`), matching `outline.rs` and the gutter marker candidates.
|
||||
let range = idx.range(name_node.start_byte(), name_node.end_byte());
|
||||
let bases = node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default();
|
||||
let bases = class_base_names(node, text);
|
||||
out.push(ClassBases { name, line: range.start.line, col: range.start.col, bases });
|
||||
});
|
||||
out
|
||||
@@ -67,6 +67,11 @@ fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bare base names a `class_definition` node writes (keyword arguments left out).
|
||||
pub(super) fn class_base_names(node: Node, text: &str) -> Vec<String> {
|
||||
node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn base_names(args: Node, text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = args.walk();
|
||||
|
||||
@@ -10,6 +10,7 @@ mod inheritance;
|
||||
pub(crate) mod lines;
|
||||
mod literals;
|
||||
mod outline;
|
||||
mod python_facts;
|
||||
mod rst;
|
||||
mod selection;
|
||||
mod sfc;
|
||||
@@ -27,6 +28,7 @@ pub use inheritance::ClassBases;
|
||||
pub use lines::{shift_pos, shift_range};
|
||||
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
|
||||
pub use outline::merge_visibility;
|
||||
pub use python_facts::{MethodBody, PythonClassFacts, PythonFacts, PythonMethodFacts};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Python class facts the inheritance inspections need: base names as written, parameter
|
||||
//! lists, decorator semantics and what a method body does. They come from the syntax tree in
|
||||
//! one pass, so the frontend reasons about the inheritance graph without parsing source text.
|
||||
//!
|
||||
//! Deliberately small: a fact exists here only when a check can decide it without guessing.
|
||||
|
||||
use super::lines::LineIndex;
|
||||
use crate::{LanguageId, Syntax};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
/// One Python module's classes, in source order.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PythonFacts {
|
||||
pub classes: Vec<PythonClassFacts>,
|
||||
}
|
||||
|
||||
/// A class definition and the methods it declares.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PythonClassFacts {
|
||||
pub name: String,
|
||||
/// The class name identifier (selection range).
|
||||
pub selection: crate::Range,
|
||||
/// Base names as written, a qualified name reduced to its last segment.
|
||||
pub bases: Vec<String>,
|
||||
pub methods: Vec<PythonMethodFacts>,
|
||||
}
|
||||
|
||||
/// A method definition the inspections can reason about.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PythonMethodFacts {
|
||||
pub name: String,
|
||||
/// The method name identifier (selection range).
|
||||
pub selection: crate::Range,
|
||||
/// Parameter names in order; `None` for a form whose forwarding cannot be compared
|
||||
/// (`*args`, `**kwargs`, keyword-only, positional-only).
|
||||
pub params: Option<Vec<String>>,
|
||||
/// Decorated with `abstractmethod` (`abc.abstractmethod` included).
|
||||
pub abstract_method: bool,
|
||||
/// Decorated with `override` from `typing` / `typing_extensions`.
|
||||
pub override_decorated: bool,
|
||||
pub body: MethodBody,
|
||||
}
|
||||
|
||||
/// What a method body does, for the shapes a check can decide without guessing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum MethodBody {
|
||||
/// `pass`, `...`, a docstring, or nothing at all.
|
||||
Empty,
|
||||
/// Exactly one `super().name(args)` call, optionally `return`ed.
|
||||
SuperCall { returns: bool, target: String, args: Vec<String> },
|
||||
/// Anything else.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Syntax {
|
||||
/// The classes of a Python `text` with the facts the inheritance checks need.
|
||||
/// Every other language returns an empty result.
|
||||
pub fn python_facts(&self, lang: &LanguageId, text: &str) -> PythonFacts {
|
||||
self.analyze(lang, text, |g, tree, text| (g.spec.name == "python").then(|| extract(tree, text)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(tree: &Tree, text: &str) -> PythonFacts {
|
||||
let idx = LineIndex::new(text);
|
||||
let imports = ImportBindings::collect(tree, text);
|
||||
let mut classes = Vec::new();
|
||||
let mut cursor = tree.walk();
|
||||
visit(&mut cursor, &mut |node| {
|
||||
if node.kind() != "class_definition" {
|
||||
return;
|
||||
}
|
||||
let Some(name_node) = node.child_by_field_name("name") else { return };
|
||||
let name = text_of(name_node, text);
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
classes.push(PythonClassFacts {
|
||||
name,
|
||||
selection: idx.range(name_node.start_byte(), name_node.end_byte()),
|
||||
bases: super::inheritance::class_base_names(node, text),
|
||||
methods: methods_of(node, text, &idx, &imports),
|
||||
});
|
||||
});
|
||||
PythonFacts { classes }
|
||||
}
|
||||
|
||||
/// The methods declared directly in `class`.
|
||||
fn methods_of(class: Node, text: &str, idx: &LineIndex, imports: &ImportBindings) -> Vec<PythonMethodFacts> {
|
||||
let Some(body) = class.child_by_field_name("body") else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = body.walk();
|
||||
for member in body.named_children(&mut cursor) {
|
||||
// A decorated method is a `decorated_definition` wrapping the `function_definition`.
|
||||
let method = match member.kind() {
|
||||
"function_definition" => member,
|
||||
"decorated_definition" => match member.child_by_field_name("definition") {
|
||||
Some(inner) if inner.kind() == "function_definition" => inner,
|
||||
_ => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
let Some(name_node) = method.child_by_field_name("name") else { continue };
|
||||
let name = text_of(name_node, text);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let decorators = decorators_of(method, text);
|
||||
out.push(PythonMethodFacts {
|
||||
name,
|
||||
selection: idx.range(name_node.start_byte(), name_node.end_byte()),
|
||||
params: parameters_of(method, text),
|
||||
abstract_method: decorators.iter().any(|d| last_segment(d) == "abstractmethod"),
|
||||
override_decorated: decorators.iter().any(|d| imports.is_typing_override(d)),
|
||||
body: body_of(method, text),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parameter names of a `function_definition`, or `None` when the signature contains a
|
||||
/// form whose forwarding cannot be compared without guessing.
|
||||
fn parameters_of(function: Node, text: &str) -> Option<Vec<String>> {
|
||||
let params = function.child_by_field_name("parameters")?;
|
||||
let mut names = Vec::new();
|
||||
let mut cursor = params.walk();
|
||||
for param in params.named_children(&mut cursor) {
|
||||
let name = match param.kind() {
|
||||
"identifier" => text_of(param, text),
|
||||
"typed_parameter" | "default_parameter" | "typed_default_parameter" => {
|
||||
let named = param
|
||||
.child_by_field_name("name")
|
||||
.or_else(|| param.child_by_field_name("pattern"))
|
||||
.or_else(|| param.named_child(0))?;
|
||||
if named.kind() != "identifier" {
|
||||
return None;
|
||||
}
|
||||
text_of(named, text)
|
||||
}
|
||||
// `*args`, `**kwargs`, `/`, `*`: the call form cannot be compared textually.
|
||||
_ => return None,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// A `function_definition`'s body, classified into the shapes a check can decide.
|
||||
fn body_of(function: Node, text: &str) -> MethodBody {
|
||||
let Some(body) = function.child_by_field_name("body") else { return MethodBody::Empty };
|
||||
// A docstring is not a statement the checks care about.
|
||||
let statements: Vec<Node> = statements_of(body).into_iter().filter(|s| !is_string(*s)).collect();
|
||||
if statements.is_empty() || statements.iter().all(|s| is_noop(*s)) {
|
||||
return MethodBody::Empty;
|
||||
}
|
||||
let [only] = statements.as_slice() else { return MethodBody::Other };
|
||||
let call = match only.kind() {
|
||||
"expression_statement" => (only.named_child(0), false),
|
||||
"return_statement" => (only.named_child(0), true),
|
||||
_ => (None, false),
|
||||
};
|
||||
match call {
|
||||
(Some(call), returns) if is_super_call(call, text) => {
|
||||
let target = call
|
||||
.child_by_field_name("function")
|
||||
.and_then(|f| f.child_by_field_name("attribute"))
|
||||
.map(|a| text_of(a, text))
|
||||
.unwrap_or_default();
|
||||
let args = call
|
||||
.child_by_field_name("arguments")
|
||||
.map(|args| {
|
||||
let mut cursor = args.walk();
|
||||
args.named_children(&mut cursor).map(|a| text_of(a, text)).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
MethodBody::SuperCall { returns, target, args }
|
||||
}
|
||||
_ => MethodBody::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `node` is a `super().name(...)` call.
|
||||
fn is_super_call(node: Node, text: &str) -> bool {
|
||||
if node.kind() != "call" {
|
||||
return false;
|
||||
}
|
||||
let Some(function) = node.child_by_field_name("function") else { return false };
|
||||
if function.kind() != "attribute" {
|
||||
return false;
|
||||
}
|
||||
function.child_by_field_name("object").is_some_and(|object| {
|
||||
object.kind() == "call"
|
||||
&& object
|
||||
.child_by_field_name("function")
|
||||
.is_some_and(|f| f.kind() == "identifier" && text_of(f, text) == "super")
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `statement` is `pass` or a bare `...`.
|
||||
fn is_noop(statement: Node) -> bool {
|
||||
match statement.kind() {
|
||||
"pass_statement" => true,
|
||||
"expression_statement" => statement.named_child(0).is_some_and(|child| child.kind() == "ellipsis"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A string expression statement: a docstring.
|
||||
fn is_string(statement: Node) -> bool {
|
||||
statement.kind() == "expression_statement" && statement.named_child(0).is_some_and(|child| child.kind() == "string")
|
||||
}
|
||||
|
||||
/// The statements of a `block`, comments excluded.
|
||||
fn statements_of(block: Node) -> Vec<Node> {
|
||||
let mut cursor = block.walk();
|
||||
block.named_children(&mut cursor).filter(|child| child.kind() != "comment").collect()
|
||||
}
|
||||
|
||||
/// The decorators written on a definition, without the `@` or the call arguments.
|
||||
fn decorators_of(definition: Node, text: &str) -> Vec<String> {
|
||||
let Some(parent) = definition.parent() else { return Vec::new() };
|
||||
if parent.kind() != "decorated_definition" {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = parent.walk();
|
||||
for decorator in parent.named_children(&mut cursor) {
|
||||
if decorator.kind() != "decorator" {
|
||||
continue;
|
||||
}
|
||||
let Some(inner) = decorator.named_child(0) else { continue };
|
||||
let target =
|
||||
if inner.kind() == "call" { inner.child_by_field_name("function").unwrap_or(inner) } else { inner };
|
||||
let name = text_of(target, text).trim().to_owned();
|
||||
if !name.is_empty() {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The last segment of a dotted name (`typing.override` -> `override`).
|
||||
fn last_segment(name: &str) -> &str {
|
||||
name.rsplit('.').next().unwrap_or(name)
|
||||
}
|
||||
|
||||
fn text_of(node: Node, text: &str) -> String {
|
||||
text.get(node.byte_range()).unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
/// The module-level `from ... import ...` bindings, so `override` can be told apart from a
|
||||
/// same-named decorator of another library.
|
||||
struct ImportBindings {
|
||||
/// (local name, module, exported name)
|
||||
bindings: Vec<(String, String, String)>,
|
||||
}
|
||||
|
||||
impl ImportBindings {
|
||||
fn collect(tree: &Tree, text: &str) -> Self {
|
||||
let mut bindings = Vec::new();
|
||||
let mut cursor = tree.walk();
|
||||
visit(&mut cursor, &mut |node| {
|
||||
if node.kind() != "import_from_statement" {
|
||||
return;
|
||||
}
|
||||
let Some(module) = node.child_by_field_name("module_name") else { return };
|
||||
let module = text_of(module, text);
|
||||
let mut names = node.walk();
|
||||
for name in node.children_by_field_name("name", &mut names) {
|
||||
let (local, exported) = match name.kind() {
|
||||
"aliased_import" => (
|
||||
name.child_by_field_name("alias").map(|n| text_of(n, text)),
|
||||
name.child_by_field_name("name").map(|n| text_of(n, text)),
|
||||
),
|
||||
_ => {
|
||||
let written = text_of(name, text);
|
||||
(Some(written.clone()), Some(written))
|
||||
}
|
||||
};
|
||||
if let (Some(local), Some(exported)) = (local, exported) {
|
||||
bindings.push((local, module.clone(), exported));
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { bindings }
|
||||
}
|
||||
|
||||
/// Whether `decorator` names `override` from `typing` / `typing_extensions`.
|
||||
fn is_typing_override(&self, decorator: &str) -> bool {
|
||||
const MODULES: [&str; 2] = ["typing", "typing_extensions"];
|
||||
let segments: Vec<&str> = decorator.split('.').filter(|s| !s.is_empty()).collect();
|
||||
match segments.as_slice() {
|
||||
// `@typing.override`, or `@t.override` after `import typing as t`.
|
||||
[prefix, "override"] => {
|
||||
MODULES.contains(prefix)
|
||||
|| self
|
||||
.bindings
|
||||
.iter()
|
||||
.find(|(local, _, _)| local == prefix)
|
||||
.is_some_and(|(_, module, _)| MODULES.contains(&module.as_str()))
|
||||
}
|
||||
// `@override`, or `@ov` after `from typing import override as ov`.
|
||||
[local] => match self.bindings.iter().find(|(bound, _, _)| bound == local) {
|
||||
Some((_, module, exported)) => exported == "override" && MODULES.contains(&module.as_str()),
|
||||
// Nothing contradicts a bare `@override`: the usual `from typing import *`.
|
||||
None => *local == "override",
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth-first pre-order traversal of all descendants of `cursor`'s node.
|
||||
fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
|
||||
loop {
|
||||
f(cursor.node());
|
||||
if cursor.goto_first_child() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
if cursor.goto_next_sibling() {
|
||||
break;
|
||||
}
|
||||
if !cursor.goto_parent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static SYNTAX: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
|
||||
|
||||
fn facts(src: &str) -> PythonFacts {
|
||||
SYNTAX.get_or_init(Syntax::new).python_facts(&LanguageId::new("python"), src)
|
||||
}
|
||||
|
||||
fn method<'a>(facts: &'a PythonFacts, class: &str, method: &str) -> &'a PythonMethodFacts {
|
||||
facts
|
||||
.classes
|
||||
.iter()
|
||||
.find(|c| c.name == class)
|
||||
.and_then(|c| c.methods.iter().find(|m| m.name == method))
|
||||
.expect("method not found")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classes_carry_bases_and_methods() {
|
||||
let f = facts(
|
||||
"class Base(ABC):\n pass\n\nclass Child(Base, pkg.Mixin):\n def run(self, x=1):\n pass\n",
|
||||
);
|
||||
assert_eq!(f.classes.len(), 2);
|
||||
assert_eq!(f.classes[0].name, "Base");
|
||||
assert_eq!(f.classes[0].bases, ["ABC"]);
|
||||
assert_eq!(f.classes[1].bases, ["Base", "Mixin"]);
|
||||
assert_eq!(f.classes[1].methods.len(), 1);
|
||||
assert_eq!(f.classes[1].selection.start.line, 3);
|
||||
assert_eq!(f.classes[1].selection.start.col, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_are_names_in_order_and_none_for_splats() {
|
||||
let f = facts(
|
||||
"class C:\n def a(self, x: int, y=2):\n pass\n\n def b(self, *args):\n pass\n\n def c(self, *, x):\n pass\n",
|
||||
);
|
||||
assert_eq!(
|
||||
method(&f, "C", "a").params.as_deref(),
|
||||
Some(["self".to_string(), "x".to_string(), "y".to_string()].as_slice())
|
||||
);
|
||||
assert_eq!(method(&f, "C", "b").params, None);
|
||||
assert_eq!(method(&f, "C", "c").params, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_shapes_are_classified() {
|
||||
let f = facts(
|
||||
"class C:\n def noop(self):\n pass\n\n def dots(self):\n ...\n\n def doc(self):\n \"\"\"text\"\"\"\n\n def forward(self, x):\n return super().run(x)\n\n def call(self):\n super().run()\n\n def other(self):\n return 1\n",
|
||||
);
|
||||
assert_eq!(method(&f, "C", "noop").body, MethodBody::Empty);
|
||||
assert_eq!(method(&f, "C", "dots").body, MethodBody::Empty);
|
||||
assert_eq!(method(&f, "C", "doc").body, MethodBody::Empty);
|
||||
assert_eq!(
|
||||
method(&f, "C", "forward").body,
|
||||
MethodBody::SuperCall { returns: true, target: "run".into(), args: vec!["x".into()] }
|
||||
);
|
||||
assert_eq!(
|
||||
method(&f, "C", "call").body,
|
||||
MethodBody::SuperCall { returns: false, target: "run".into(), args: vec![] }
|
||||
);
|
||||
assert_eq!(method(&f, "C", "other").body, MethodBody::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorators_are_their_names_and_semantics() {
|
||||
let f = facts(
|
||||
"from abc import abstractmethod\nfrom typing import override\n\nclass C:\n @abstractmethod\n def a(self):\n pass\n\n @override\n def b(self):\n pass\n\n @functools.cache\n def c(self):\n pass\n",
|
||||
);
|
||||
assert!(method(&f, "C", "a").abstract_method);
|
||||
assert!(!method(&f, "C", "a").override_decorated);
|
||||
assert!(method(&f, "C", "b").override_decorated);
|
||||
assert!(!method(&f, "C", "c").override_decorated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_of_another_library_is_not_typing_override() {
|
||||
let f = facts("from mylib import override\n\nclass C:\n @override\n def a(self):\n pass\n");
|
||||
assert!(!method(&f, "C", "a").override_decorated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliased_override_and_qualified_forms_are_recognised() {
|
||||
let f = facts(
|
||||
"from typing import override as ov\n\nclass C:\n @ov\n def a(self):\n pass\n\nclass D:\n @typing_extensions.override\n def b(self):\n pass\n",
|
||||
);
|
||||
assert!(method(&f, "C", "a").override_decorated);
|
||||
assert!(method(&f, "D", "b").override_decorated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_classes_and_other_languages() {
|
||||
let f = facts("class Outer:\n class Inner:\n def m(self):\n pass\n");
|
||||
let names: Vec<&str> = f.classes.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["Outer", "Inner"]);
|
||||
let other = SYNTAX.get_or_init(Syntax::new).python_facts(&LanguageId::new("rust"), "class C {}");
|
||||
assert_eq!(other.classes.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -41,20 +41,44 @@ pub struct ClientHandler {
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// The diagnostics in `items` (a JSON array) that deserialize. A server's malformed one is
|
||||
/// skipped with a warning, so the rest of the file's problems still show.
|
||||
pub(crate) fn lenient_diagnostics(server: &str, items: Value) -> Vec<lsp::Diagnostic> {
|
||||
let Value::Array(items) = items else { return Vec::new() };
|
||||
let mut skipped = 0usize;
|
||||
let parsed: Vec<lsp::Diagnostic> = items
|
||||
.into_iter()
|
||||
.filter_map(|mut item| {
|
||||
crate::virtual_doc::normalize_uris(&mut item);
|
||||
match serde_json::from_value(item) {
|
||||
Ok(diagnostic) => Some(diagnostic),
|
||||
Err(_) => {
|
||||
skipped += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if skipped > 0 {
|
||||
tracing::warn!("{server}: skipped {skipped} malformed diagnostic(s)");
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
impl ClientHandler {
|
||||
pub fn new(mgr: Weak<LspManager>, server: Arc<Server>, generation: u64) -> Self {
|
||||
Self { mgr, server, generation }
|
||||
}
|
||||
|
||||
fn encoding(&self) -> Encoding {
|
||||
self.server.running_gen(self.generation).map(|r| r.caps.read().encoding).unwrap_or_default()
|
||||
self.server.initialized_gen(self.generation).map(|r| r.caps.read().encoding).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Cancelling task `task` asks this server instance to stop the work of progress `token`.
|
||||
fn cancel_handle(&self, task: TaskId, token: &Value) -> ide_core::task::CancelHandle {
|
||||
let (server, generation, token) = (self.server.clone(), self.generation, token.clone());
|
||||
ide_core::task::on_cancel(task, move || {
|
||||
if let Some(run) = server.running_gen(generation)
|
||||
if let Some(run) = server.initialized_gen(generation)
|
||||
&& let Err(e) = run.peer.notify("window/workDoneProgress/cancel", json!({ "token": token }))
|
||||
{
|
||||
tracing::debug!("{}: cannot cancel progress: {e}", server.name);
|
||||
@@ -176,7 +200,7 @@ impl ClientHandler {
|
||||
loop {
|
||||
tokio::time::sleep(PROGRESS_SWEEP).await;
|
||||
let Some(mgr) = weak.upgrade() else { return };
|
||||
if server.running_gen(generation).is_none() || !server.has_progress(&token) {
|
||||
if !server.is_live(generation) || !server.has_progress(&token) {
|
||||
return; // the instance is gone, or the task ended normally
|
||||
}
|
||||
if server.progress_silent(&token, PROGRESS_STALE) {
|
||||
@@ -251,18 +275,24 @@ impl ClientHandler {
|
||||
|
||||
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
|
||||
// Problems come only from servers used for `diagnostics`.
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
if self.server.initialized_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
return;
|
||||
}
|
||||
let mut params = params;
|
||||
crate::virtual_doc::normalize_uris(&mut params);
|
||||
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
// One malformed diagnostic must not take the others of the file with it.
|
||||
let raw = params.get_mut("diagnostics").map(Value::take);
|
||||
if let Some(fields) = params.as_object_mut() {
|
||||
fields.insert("diagnostics".into(), json!([]));
|
||||
}
|
||||
let mut params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: bad publishDiagnostics: {e}", self.server.name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
params.diagnostics = lenient_diagnostics(&self.server.name, raw.unwrap_or(Value::Null));
|
||||
let Some(path) = uri_to_path(params.uri.as_str())
|
||||
.or_else(|| crate::notebook::is_cell_uri(params.uri.as_str()).then(|| PathBuf::from(params.uri.as_str())))
|
||||
else {
|
||||
@@ -480,7 +510,7 @@ impl Handler for ClientHandler {
|
||||
crate::kotlin_lsp::record_import_state(&self.server, self.generation, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
if self.server.running_gen(self.generation).is_some() {
|
||||
if self.server.initialized_gen(self.generation).is_some() {
|
||||
self.server.set_java_ready();
|
||||
}
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
|
||||
@@ -715,33 +715,35 @@ mod tests {
|
||||
server.set_diagnostics(a.path.clone(), vec![diag(2, 5, "a"), diag(0, 1, "outside"), diag(4, 6, "crosses")]);
|
||||
let items = server.set_diagnostics(b.path.clone(), vec![diag(7, 10, "b")]);
|
||||
mgr.emit_diagnostics(&server, b.path, items, Encoding::Utf16);
|
||||
let latest = || {
|
||||
events
|
||||
.lock()
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
// The SQL server's failed launch reports its status from a runtime thread; while that
|
||||
// thread drains the server's event queue, events emitted here reach the sink from it.
|
||||
let latest = |want: &dyn Fn(&[ide_core::Diagnostic]) -> bool| {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let items = events.lock().iter().rev().find_map(|e| match e {
|
||||
ide_core::IdeEvent::Diagnostics { path: p, source, items }
|
||||
if p.0 == path && source == &server.name =>
|
||||
{
|
||||
Some(items.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
});
|
||||
match items {
|
||||
Some(items) if want(&items) => return items,
|
||||
_ if std::time::Instant::now() > deadline => panic!("latest host diagnostics: {items:?}"),
|
||||
_ => std::thread::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
};
|
||||
assert_eq!(latest().iter().map(|d| d.message.as_str()).collect::<Vec<_>>().len(), 3);
|
||||
let messages = |items: &[ide_core::Diagnostic]| items.iter().map(|d| d.message.clone()).collect::<Vec<_>>();
|
||||
let merged = latest(&|items| items.len() == 3);
|
||||
assert!(
|
||||
latest()
|
||||
.iter()
|
||||
.filter(|d| d.message != "native")
|
||||
.all(|d| d.source.as_deref() == Some(server.name.as_str()))
|
||||
merged.iter().filter(|d| d.message != "native").all(|d| d.source.as_deref() == Some(server.name.as_str()))
|
||||
);
|
||||
mgr.set_injections(host, 1, vec![injection(7, 10)]);
|
||||
assert_eq!(latest().iter().map(|d| d.message.clone()).collect::<Vec<_>>(), ["native", "b"]);
|
||||
latest(&|items| messages(items) == ["native", "b"]);
|
||||
mgr.set_injections(host, 1, vec![]);
|
||||
assert_eq!(latest()[0].message, "native");
|
||||
assert_eq!(latest().len(), 1);
|
||||
latest(&|items| messages(items) == ["native"]);
|
||||
assert!(
|
||||
events.lock().iter().all(|e| !matches!(e, ide_core::IdeEvent::Diagnostics { path, .. } if reserved(path)))
|
||||
);
|
||||
|
||||
@@ -103,7 +103,7 @@ impl Launcher for ProcessLauncher {
|
||||
// A plugin's server: the approved program, unchanged since.
|
||||
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
|
||||
None => ide_core::tool_path::find_command(&config.command, &search, root)
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b))
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b, root))
|
||||
.and_then(|b| reject_dead_shim(b, &search, root)),
|
||||
};
|
||||
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
|
||||
@@ -208,34 +208,16 @@ fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option<Path
|
||||
/// toolchain lacks the component (common for rust-analyzer). In that case use
|
||||
/// the newest installed toolchain that ships the binary; with none, the tool is
|
||||
/// missing (an install hint, not a server that crashes on every restart).
|
||||
fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
|
||||
/// The probe runs in the project's root, where a `rust-toolchain.toml` picks the toolchain.
|
||||
fn resolve_rustup_proxy(command: &str, binary: PathBuf, root: &Path) -> Option<PathBuf> {
|
||||
let is_proxy = binary.parent().is_some_and(|d| d.ends_with(".cargo/bin"));
|
||||
if !is_proxy || command.contains('/') {
|
||||
return Some(binary);
|
||||
}
|
||||
let works = ide_core::process::prepare(&mut std::process::Command::new(&binary))
|
||||
.arg("--version")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if works {
|
||||
if proxy_works(&binary, root) {
|
||||
return Some(binary);
|
||||
}
|
||||
let toolchains = std::env::var_os("RUSTUP_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".rustup")))
|
||||
.map(|h| h.join("toolchains"));
|
||||
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = toolchains
|
||||
.and_then(|dir| std::fs::read_dir(dir).ok())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path().join("bin").join(command))
|
||||
.filter(|p| p.is_file())
|
||||
.filter_map(|p| Some((p.metadata().ok()?.modified().ok()?, p)))
|
||||
.collect();
|
||||
let mut candidates = rustup_home().map(|h| toolchain_binaries(&h.join("toolchains"), command)).unwrap_or_default();
|
||||
// Prefer stable-looking toolchains, then the most recently installed.
|
||||
candidates.sort_by_key(|(mtime, p)| (!p.to_string_lossy().contains("nightly"), *mtime));
|
||||
match candidates.pop() {
|
||||
@@ -250,6 +232,57 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the proxy runs for the toolchain active in `root`. A probe that hangs or can't
|
||||
/// be waited for counts as working: the real start decides.
|
||||
fn proxy_works(binary: &Path, root: &Path) -> bool {
|
||||
let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(binary))
|
||||
.arg("--version")
|
||||
.current_dir(root)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return status.success(),
|
||||
Ok(None) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20))
|
||||
}
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where rustup keeps its toolchains: `RUSTUP_HOME`, else `.rustup` in the user's home
|
||||
/// (`HOME`, which a GUI app on Windows doesn't have, or `USERPROFILE`).
|
||||
fn rustup_home() -> Option<PathBuf> {
|
||||
std::env::var_os("RUSTUP_HOME").map(PathBuf::from).or_else(|| {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
|
||||
Some(PathBuf::from(home).join(".rustup"))
|
||||
})
|
||||
}
|
||||
|
||||
/// The `command` binary (with the platform's executable suffix) of every toolchain in
|
||||
/// `toolchains`, with its modification time.
|
||||
fn toolchain_binaries(toolchains: &Path, command: &str) -> Vec<(std::time::SystemTime, PathBuf)> {
|
||||
let file = format!("{command}{}", std::env::consts::EXE_SUFFIX);
|
||||
std::fs::read_dir(toolchains)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path().join("bin").join(&file))
|
||||
.filter(|p| p.is_file())
|
||||
.filter_map(|p| Some((p.metadata().ok()?.modified().ok()?, p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Workspace root for `file`: the outermost ancestor within `project_root`
|
||||
/// containing a root marker (so a Cargo/npm workspace gets one server, not one
|
||||
/// per member), else `project_root`. `None` when `file` is outside the project.
|
||||
@@ -363,7 +396,24 @@ mod proxy_tests {
|
||||
#[test]
|
||||
fn non_proxy_binaries_are_kept() {
|
||||
let p = PathBuf::from("/usr/bin/true");
|
||||
assert_eq!(resolve_rustup_proxy("true", p.clone()), Some(p));
|
||||
assert_eq!(resolve_rustup_proxy("true", p.clone(), Path::new(".")), Some(p));
|
||||
}
|
||||
|
||||
/// The binary is `rust-analyzer.exe` on Windows: a lookup of the bare name found nothing there.
|
||||
#[test]
|
||||
fn toolchain_binaries_carry_the_platform_executable_suffix() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = format!("rust-analyzer{}", std::env::consts::EXE_SUFFIX);
|
||||
for toolchain in ["stable-x86_64", "nightly-x86_64"] {
|
||||
std::fs::create_dir_all(tmp.path().join(toolchain).join("bin")).unwrap();
|
||||
}
|
||||
std::fs::write(tmp.path().join("stable-x86_64/bin").join(&file), "").unwrap();
|
||||
// Another tool, and a bare name without the suffix where one is required.
|
||||
std::fs::write(tmp.path().join("nightly-x86_64/bin").join("rustfmt"), "").unwrap();
|
||||
let found = toolchain_binaries(tmp.path(), "rust-analyzer");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].1, tmp.path().join("stable-x86_64/bin").join(&file));
|
||||
assert!(toolchain_binaries(&tmp.path().join("missing"), "rust-analyzer").is_empty());
|
||||
}
|
||||
|
||||
/// Exercises the real machine setup when a broken rustup proxy is present.
|
||||
@@ -372,7 +422,7 @@ mod proxy_tests {
|
||||
fn broken_rustup_proxy_falls_back_to_a_toolchain() {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").unwrap());
|
||||
let proxy = home.join(".cargo/bin/rust-analyzer");
|
||||
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone()).unwrap();
|
||||
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone(), Path::new(".")).unwrap();
|
||||
eprintln!("resolved: {}", resolved.display());
|
||||
assert_ne!(resolved, proxy);
|
||||
}
|
||||
|
||||
@@ -343,6 +343,10 @@ impl<T> Outcome<T> {
|
||||
/// How often a rename the server withdrew (`ContentModified`) is asked, and the pause between.
|
||||
const RENAME_ATTEMPTS: u32 = 5;
|
||||
const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
/// How often a semantic tokens, inlay hints or code lens request the server withdrew
|
||||
/// (`ContentModified`) is asked again, and the pause between.
|
||||
const STALE_RETRIES: u32 = 2;
|
||||
const STALE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
|
||||
/// Implementation requests of one line-marker pass in flight at once.
|
||||
const IMPLEMENTATION_CONCURRENCY: usize = 4;
|
||||
@@ -654,8 +658,9 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// The servers serving `path`: every regular one, and each `fallback` one
|
||||
/// only while all servers listed before it are missing. One that requires a
|
||||
/// root marker serves only files below one, and stands in for no other.
|
||||
/// only while all servers listed before it have given up (missing, not
|
||||
/// launchable, or crashed for good). One that requires a root marker serves
|
||||
/// only files below one, and stands in for no other.
|
||||
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
|
||||
if let Some(servers) = self.arduino_servers(language, path) {
|
||||
return servers;
|
||||
@@ -672,7 +677,7 @@ impl LspManager {
|
||||
continue;
|
||||
}
|
||||
let server = self.server_for(sc, language, path);
|
||||
available_before |= !sc.require_root_marker && server.status() != ide_core::event::ServerStatus::Missing;
|
||||
available_before |= !sc.require_root_marker && !server.gave_up();
|
||||
out.push(server);
|
||||
}
|
||||
out
|
||||
@@ -748,9 +753,14 @@ impl LspManager {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
// A server dropped meanwhile (disabled in settings, shadowed by another) must not come
|
||||
// back as a process nothing lists, serves or stops: a crash backoff can outlive it.
|
||||
if !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
return;
|
||||
}
|
||||
let workspace = is_local_jdtls(&server.config).then(|| self.jdtls_workspace_state());
|
||||
if let Some(state) = &workspace {
|
||||
if state.transitioning || !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
if state.transitioning {
|
||||
return;
|
||||
}
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
@@ -861,24 +871,28 @@ impl LspManager {
|
||||
}
|
||||
Err(LaunchError::Config(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
server.fail_start(
|
||||
if server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Misconfigured,
|
||||
Some(message),
|
||||
);
|
||||
) {
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(LaunchError::Failed(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
server.fail_start(
|
||||
if server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Crashed,
|
||||
Some(message),
|
||||
);
|
||||
) {
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -920,9 +934,18 @@ impl LspManager {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: initialize failed: {e}", server.name);
|
||||
if matches!(e, RpcError::Closed) {
|
||||
match e {
|
||||
// The server is exiting: let it, so the crash report has its exit code and last output.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
RpcError::Closed => {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
}
|
||||
RpcError::Timeout => {
|
||||
server.set_start_error(
|
||||
generation,
|
||||
format!("it did not answer initialize within {} s", INITIALIZE_TIMEOUT.as_secs()),
|
||||
);
|
||||
}
|
||||
e => server.set_start_error(generation, format!("initialize failed ({e})")),
|
||||
}
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
// Treated like a crash so transient failures retry with backoff.
|
||||
@@ -1008,11 +1031,15 @@ impl LspManager {
|
||||
self.forget(std::slice::from_ref(&server));
|
||||
return;
|
||||
}
|
||||
if let Some(delay) = delay {
|
||||
tokio::time::sleep(delay).await;
|
||||
if server.is_idle_gen(generation) {
|
||||
self.ensure_started(&server);
|
||||
match delay {
|
||||
Some(delay) => {
|
||||
tokio::time::sleep(delay).await;
|
||||
if server.is_idle_gen(generation) {
|
||||
self.ensure_started(&server);
|
||||
}
|
||||
}
|
||||
// Gave up (crashed repeatedly, or its build expired): the fallbacks it hid take over.
|
||||
None => self.attach_fallbacks(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1458,6 +1485,28 @@ impl LspManager {
|
||||
result
|
||||
}
|
||||
|
||||
/// [`Self::call`], asked again when the server withdrew the answer because the text changed
|
||||
/// (`ContentModified`, which arrives as a cancellation as no cancel token is passed).
|
||||
/// The client advertises `retryOnContentModified` for the methods that use it.
|
||||
async fn call_retrying<P: Serialize + Clone, R: DeserializeOwned>(
|
||||
&self,
|
||||
t: &Target,
|
||||
method: &str,
|
||||
params: P,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<R> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
match self.call(t, method, params.clone(), None, timeout).await {
|
||||
Err(e) if e.kind == ErrorKind::Cancelled && attempt < STALE_RETRIES => {
|
||||
attempt += 1;
|
||||
tokio::time::sleep(STALE_RETRY_DELAY).await;
|
||||
}
|
||||
result => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- features ----
|
||||
pub async fn completion(
|
||||
&self,
|
||||
@@ -1718,6 +1767,38 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Library classes in `jar:` targets that have no cached sources jar: the official Kotlin
|
||||
/// LSP decompiles them to signatures only (`/* compiled code */`). When jdtls is running,
|
||||
/// the same class from its Java model instead, decompiled with method bodies and navigable.
|
||||
/// Its documents belong to jdtls ([`Self::virtual_document`] records it).
|
||||
async fn jdt_library_classes(&self, snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
|
||||
let jar_class = |l: &Location| l.uri.as_deref().is_some_and(|u| u.starts_with("jar:") && u.ends_with(".class"));
|
||||
if !locs.iter().any(jar_class)
|
||||
|| !self.servers.lock().iter().any(|s| s.name == jdtls::SERVER && s.running().is_some())
|
||||
{
|
||||
return locs;
|
||||
}
|
||||
let name = library_source::identifier_at(snap.text.line(pos.line as usize), pos.col).map(str::to_owned);
|
||||
let mut out = Vec::with_capacity(locs.len());
|
||||
for loc in locs {
|
||||
let entry = loc.uri.as_deref().filter(|_| jar_class(&loc)).and_then(library_source::parse);
|
||||
let Some(entry) = entry else {
|
||||
out.push(loc);
|
||||
continue;
|
||||
};
|
||||
let Ok(uri) = self.java_class_file(Path::new(""), Some((entry.jar, entry.entry))).await else {
|
||||
out.push(loc);
|
||||
continue;
|
||||
};
|
||||
let range = match (&name, self.virtual_document(&uri).await) {
|
||||
(Some(name), Ok(doc)) => library_source::declaration_range(&doc.text, name),
|
||||
_ => Range::default(),
|
||||
};
|
||||
out.push(Location { path: uri.clone().into(), range, uri: Some(uri) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn goto_in(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
@@ -1759,6 +1840,7 @@ impl LspManager {
|
||||
let locs = library_sources(snap, &self.project_root, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
self.note_arduino_sources(&t.server, &locs);
|
||||
let locs = self.jdt_library_classes(snap, pos, locs).await;
|
||||
let locs = merge_locations(first, locs);
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
@@ -2892,11 +2974,10 @@ impl LspManager {
|
||||
async move {
|
||||
(
|
||||
t,
|
||||
self.call::<_, Option<Vec<lsp::InlayHint>>>(
|
||||
self.call_retrying::<_, Option<Vec<lsp::InlayHint>>>(
|
||||
t,
|
||||
"textDocument/inlayHint",
|
||||
params,
|
||||
None,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await,
|
||||
@@ -2927,7 +3008,8 @@ impl LspManager {
|
||||
self.ready_targets(&snap.servers, |c| c.semantic_tokens_full() && c.semantic_legend.is_some()).await;
|
||||
let Some(t) = targets.first() else { return Ok(None) };
|
||||
let params = json!({ "textDocument": snap.ident() });
|
||||
let r: Option<Value> = self.call(t, "textDocument/semanticTokens/full", params, None, DEFAULT_TIMEOUT).await?;
|
||||
let r: Option<Value> =
|
||||
self.call_retrying(t, "textDocument/semanticTokens/full", params, DEFAULT_TIMEOUT).await?;
|
||||
let Some(data) = r.as_ref().and_then(|v| v.get("data")).and_then(Value::as_array) else { return Ok(None) };
|
||||
let data: Vec<i64> = data.iter().map(|n| n.as_i64().unwrap_or(0)).collect();
|
||||
let (enc, legend) = {
|
||||
@@ -2948,7 +3030,7 @@ impl LspManager {
|
||||
let params = json!({ "textDocument": snap.ident() });
|
||||
async move {
|
||||
let lenses = self
|
||||
.call::<_, Option<Vec<lsp::CodeLens>>>(t, "textDocument/codeLens", params, None, DEFAULT_TIMEOUT)
|
||||
.call_retrying::<_, Option<Vec<lsp::CodeLens>>>(t, "textDocument/codeLens", params, DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
let lenses = lenses.unwrap_or_default();
|
||||
if !t.caps().has_sub("codeLensProvider", "resolveProvider") {
|
||||
@@ -3097,6 +3179,21 @@ impl LspManager {
|
||||
Ok(CallHierarchy { items: out, incomplete })
|
||||
}
|
||||
|
||||
/// The type at `pos` as the server's type hierarchy names it (with its `detail`, the
|
||||
/// package for Java); `None` when there is no type there.
|
||||
pub async fn prepare_type_hierarchy(&self, doc: DocId, pos: Pos) -> IdeResult<Option<HierarchyItem>> {
|
||||
let (snap, targets) = self.doc_targets(doc, "type hierarchy", |c| c.has("typeHierarchyProvider")).await?;
|
||||
let t = &targets[0];
|
||||
let items: Option<Vec<serde_json::Value>> =
|
||||
self.call(t, "textDocument/prepareTypeHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?;
|
||||
let conv = self.conv(t.enc());
|
||||
Ok(items
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(convert::type_item_from_json)
|
||||
.find_map(|i| convert::type_item(&conv, &i)))
|
||||
}
|
||||
|
||||
pub async fn type_hierarchy(
|
||||
&self,
|
||||
doc: DocId,
|
||||
@@ -3855,31 +3952,41 @@ impl LspManager {
|
||||
languages: s.languages(),
|
||||
status: s.status(),
|
||||
pid: s.running().and_then(|r| r.peer.pid()),
|
||||
label: s.display_name(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> {
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.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);
|
||||
let s = {
|
||||
// Only the restart itself holds the lock: a start can take minutes (a Gradle or
|
||||
// Maven import), and `reconfigure` and `shutdown_all` wait for it.
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.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);
|
||||
// Started again: it shadows the fallbacks that took over while it was down.
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
s
|
||||
};
|
||||
match s.wait_ready(Duration::from_secs(60)).await {
|
||||
Some(_) => Ok(()),
|
||||
// Still importing the project: the restart worked, the server is just not ready yet.
|
||||
None if s.is_starting() => Ok(()),
|
||||
None => {
|
||||
Err(IdeError::unavailable(s.status_message().unwrap_or_else(|| format!("{} failed to start", s.name))))
|
||||
}
|
||||
|
||||
@@ -136,7 +136,8 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
return Some((entry.clone(), text));
|
||||
}
|
||||
}
|
||||
// A facade renamed with `@file:JvmName("…")`.
|
||||
// A facade renamed with `@file:JvmName("…")`, or a class declared in a file named otherwise
|
||||
// (`UserDto` in `Models.kt`).
|
||||
let kotlin_files: Vec<String> = zip
|
||||
.file_names()
|
||||
.filter_map(|n| n.ok().map(|n| n.into_owned()))
|
||||
@@ -145,7 +146,7 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
let jvm_name = format!("@file:JvmName(\"{outer}\")");
|
||||
for entry in kotlin_files {
|
||||
if let Some(text) = read(&entry)
|
||||
&& text.text.contains(&jvm_name)
|
||||
&& (text.text.contains(&jvm_name) || declares_type(&text.text, outer))
|
||||
{
|
||||
return Some((entry, text));
|
||||
}
|
||||
@@ -153,6 +154,26 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `text` declares a type called `name` (`class`, `interface`, `object`, `typealias`):
|
||||
/// how a Kotlin file that is not named after its class is recognised.
|
||||
fn declares_type(text: &str, name: &str) -> bool {
|
||||
const KINDS: [&str; 4] = ["class", "interface", "object", "typealias"];
|
||||
text.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("//") || line.starts_with("/*") || line.starts_with('*') {
|
||||
return false;
|
||||
}
|
||||
let mut words =
|
||||
line.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$' || c == '`')).filter(|w| !w.is_empty());
|
||||
let mut previous = words.next();
|
||||
words.any(|word| {
|
||||
let hit = previous.is_some_and(|p| KINDS.contains(&p)) && word.trim_matches('`') == name;
|
||||
previous = Some(word);
|
||||
hit
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Where `name` is declared in a Kotlin or Java source `text` (the start of the
|
||||
/// name), else the start of the file.
|
||||
pub(crate) fn declaration_range(text: &str, name: &str) -> Range {
|
||||
@@ -245,6 +266,22 @@ pub(crate) fn folder_class(uri: &str) -> Option<(String, String)> {
|
||||
Some((package.replace('.', "/"), name.to_owned()))
|
||||
}
|
||||
|
||||
/// Kotlin sources larger than this are not searched for a class declared in a file named otherwise.
|
||||
const MAX_SOURCE: u64 = 1 << 20;
|
||||
|
||||
/// The files of the project's source trees: build output and ignored files are skipped.
|
||||
fn walk_sources(root: &Path) -> impl Iterator<Item = ignore::DirEntry> {
|
||||
ignore::WalkBuilder::new(root)
|
||||
.filter_entry(|e| {
|
||||
let output = matches!(e.file_name().to_str(), Some("target" | "build" | "out" | "node_modules"));
|
||||
// A package may be named `build`: only outside source trees is it output.
|
||||
!output || e.path().components().any(|c| c.as_os_str() == "src")
|
||||
})
|
||||
.build()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
|
||||
}
|
||||
|
||||
/// The project's source of `class` (`Name`, `Outer$Inner`, `FileKt`) in `package`
|
||||
/// (`/`-separated): a `.kt` / `.java` file of that name declaring the package. Build
|
||||
/// output and ignored files are skipped.
|
||||
@@ -255,19 +292,24 @@ pub(crate) fn project_class_source(root: &Path, package: &str, class: &str) -> O
|
||||
names.push(format!("{file}.kt"));
|
||||
}
|
||||
let package = package.replace('/', ".");
|
||||
let mut found: Vec<PathBuf> = ignore::WalkBuilder::new(root)
|
||||
.filter_entry(|e| {
|
||||
let output = matches!(e.file_name().to_str(), Some("target" | "build" | "out" | "node_modules"));
|
||||
// A package may be named `build`: only outside source trees is it output.
|
||||
!output || e.path().components().any(|c| c.as_os_str() == "src")
|
||||
})
|
||||
.build()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
|
||||
let mut found: Vec<PathBuf> = walk_sources(root)
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| names.iter().any(|name| name == n)))
|
||||
.map(|e| e.into_path())
|
||||
.filter(|p| std::fs::read_to_string(p).is_ok_and(|text| declared_package(&text) == package))
|
||||
.collect();
|
||||
// A class declared in a file named otherwise (`UserDto` in `Models.kt`).
|
||||
if found.is_empty() {
|
||||
found = walk_sources(root)
|
||||
.filter(|e| {
|
||||
e.path().extension().is_some_and(|x| x == "kt") && e.metadata().is_ok_and(|m| m.len() <= MAX_SOURCE)
|
||||
})
|
||||
.map(|e| e.into_path())
|
||||
.filter(|p| {
|
||||
std::fs::read_to_string(p)
|
||||
.is_ok_and(|text| declared_package(&text) == package && declares_type(&text, outer))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// The directory matching the package first, then by name order.
|
||||
found.sort_by_key(|p| {
|
||||
let dir = p.parent().map(|d| d.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
@@ -358,6 +400,10 @@ mod tests {
|
||||
"@file:JvmName(\"BootUtils\")\npackage org.springframework.boot\nfun util() {}",
|
||||
),
|
||||
("org/springframework/boot/Builder.kt", "class Builder"),
|
||||
(
|
||||
"org/springframework/boot/Models.kt",
|
||||
"package org.springframework.boot\n\n// class NotMe\ndata class UserDto(val id: Int)\nenum class Role { A }\ninterface Repo\nobject Registry\ntypealias Ids = List<Int>\n",
|
||||
),
|
||||
("Root.java", "class Root {}"),
|
||||
];
|
||||
jar(&m2.join("spring-boot-3.5.16.jar"), &[]);
|
||||
@@ -373,6 +419,11 @@ mod tests {
|
||||
("org/springframework/boot/SpringApplication$Running.class", "SpringApplication.java"),
|
||||
("org/springframework/boot/BootUtils.class", "Renamed.kt"),
|
||||
("org/springframework/boot/Builder.class", "Builder.kt"),
|
||||
// Declared in a file that is not named after them.
|
||||
("org/springframework/boot/UserDto.class", "Models.kt"),
|
||||
("org/springframework/boot/Role$A.class", "Models.kt"),
|
||||
("org/springframework/boot/Repo.class", "Models.kt"),
|
||||
("org/springframework/boot/Registry.class", "Models.kt"),
|
||||
("Root.class", "Root.java"),
|
||||
] {
|
||||
let (source_uri, text) =
|
||||
@@ -385,6 +436,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
|
||||
assert!(class_source(&uri("org/springframework/boot/NotMe.class"), None).is_none(), "only in a comment");
|
||||
}
|
||||
// No sources jar: nothing to show from here.
|
||||
let lone = tmp.path().join("lone/x-1.jar");
|
||||
@@ -513,6 +565,11 @@ mod tests {
|
||||
write("src/main/kotlin/com/x/build/Tool.kt", "package com.x.build\n\nclass Tool\n");
|
||||
write("src/main/kotlin/flat/Elsewhere.kt", "");
|
||||
write("src/main/kotlin/Loose.kt", "package flat\n\nobject Loose\n");
|
||||
write(
|
||||
"src/main/kotlin/test001/Models.kt",
|
||||
"package test001\n\n// class NotMe\ndata class UserDto(val id: Int)\n",
|
||||
);
|
||||
write("src/main/kotlin/other/Models.kt", "package other\n\ndata class UserDto(val name: String)\n");
|
||||
let source = |package: &str, class: &str| {
|
||||
project_class_source(root, package, class).map(|p| p.strip_prefix(root).unwrap().to_owned())
|
||||
};
|
||||
@@ -523,6 +580,10 @@ mod tests {
|
||||
assert_eq!(source("com/x/build", "Tool"), Some(PathBuf::from("src/main/kotlin/com/x/build/Tool.kt")));
|
||||
assert_eq!(source("flat", "Loose"), Some(PathBuf::from("src/main/kotlin/Loose.kt")), "any directory");
|
||||
assert_eq!(source("test001", "Missing"), None);
|
||||
// Declared in a file named otherwise: the one of the package, not another package's.
|
||||
assert_eq!(source("test001", "UserDto"), Some(PathBuf::from("src/main/kotlin/test001/Models.kt")));
|
||||
assert_eq!(source("other", "UserDto"), Some(PathBuf::from("src/main/kotlin/other/Models.kt")));
|
||||
assert_eq!(source("test001", "NotMe"), None, "only in a comment");
|
||||
assert_eq!(declared_package("package a.b.c;\n"), "a.b.c");
|
||||
assert_eq!(declared_package("class A"), "");
|
||||
}
|
||||
|
||||
@@ -166,13 +166,10 @@ impl LspManager {
|
||||
let result_id = report.get("resultId").and_then(Value::as_str).map(str::to_owned);
|
||||
let items = match report.get("kind").and_then(Value::as_str) {
|
||||
Some("unchanged") => None,
|
||||
_ => match serde_json::from_value(report.get("items").cloned().unwrap_or(json!([]))) {
|
||||
Ok(items) => Some(items),
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: bad textDocument/diagnostic report: {e}", t.server.name);
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => Some(crate::handler::lenient_diagnostics(
|
||||
&t.server.name,
|
||||
report.get("items").cloned().unwrap_or(json!([])),
|
||||
)),
|
||||
};
|
||||
let shown = t.server.set_pulled(t.path.clone(), result_id, items);
|
||||
let enc = t.run.caps.read().encoding;
|
||||
|
||||
@@ -112,8 +112,11 @@ pub struct Server {
|
||||
status: Mutex<(ServerStatus, Option<String>)>,
|
||||
/// Consecutive crashes of short-lived instances.
|
||||
crashes: Mutex<usize>,
|
||||
/// When the current instance was launched.
|
||||
/// When the current instance became ready: only time spent running counts as uptime,
|
||||
/// not the time spent starting (an `initialize` that hangs for minutes is not a stable run).
|
||||
started: Mutex<Option<Instant>>,
|
||||
/// Why the current instance's `initialize` failed, for the crash report.
|
||||
start_error: Mutex<Option<(u64, String)>>,
|
||||
stderr: Mutex<VecDeque<String>>,
|
||||
pub progress: Mutex<HashMap<String, Progress>>,
|
||||
/// What the editor shows per path: the pushed and the pulled diagnostics together.
|
||||
@@ -177,6 +180,7 @@ impl Server {
|
||||
status: Mutex::new((ServerStatus::Stopped, None)),
|
||||
crashes: Mutex::new(0),
|
||||
started: Mutex::new(None),
|
||||
start_error: Mutex::new(None),
|
||||
stderr: Mutex::new(VecDeque::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
diagnostics: Mutex::new(HashMap::new()),
|
||||
@@ -213,6 +217,11 @@ impl Server {
|
||||
config
|
||||
}
|
||||
|
||||
/// The configured server's display name for the current (or last) instance.
|
||||
pub fn display_name(&self) -> Option<String> {
|
||||
self.effective.lock().as_ref().and_then(|c| c.display_name.clone())
|
||||
}
|
||||
|
||||
pub fn set_effective_config(&self, generation: u64, config: LanguageServerConfig) {
|
||||
let st = self.state.lock();
|
||||
if st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_)) {
|
||||
@@ -252,7 +261,8 @@ impl Server {
|
||||
st.conn = Conn::Starting;
|
||||
st.restarting = false;
|
||||
*self.start_cancel.lock() = CancellationToken::new();
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
*self.started.lock() = None;
|
||||
*self.start_error.lock() = None;
|
||||
// A crash report quotes only this instance's output, not its predecessors'.
|
||||
self.stderr.lock().clear();
|
||||
self.import_state.send_replace(None);
|
||||
@@ -300,9 +310,15 @@ impl Server {
|
||||
}
|
||||
st.conn = Conn::Ready(run);
|
||||
*self.initializing.lock() = None;
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
|
||||
/// Records why instance `generation` failed to initialize, so its crash report says so.
|
||||
pub fn set_start_error(&self, generation: u64, reason: String) {
|
||||
*self.start_error.lock() = Some((generation, reason));
|
||||
}
|
||||
|
||||
/// Wakes requests waiting for the instance (after status and documents are in place).
|
||||
pub fn announce_ready(&self, generation: u64) {
|
||||
let st = self.state.lock();
|
||||
@@ -322,6 +338,13 @@ impl Server {
|
||||
matches!(self.state.lock().conn, Conn::Starting)
|
||||
}
|
||||
|
||||
/// The server will not run until something changes: its binary is missing, its
|
||||
/// configuration can't launch it, or it crashed too often (or its build expired).
|
||||
/// Servers that crashed and are about to restart are not given up on.
|
||||
pub fn gave_up(&self) -> bool {
|
||||
matches!(self.state.lock().conn, Conn::Missing | Conn::Failed)
|
||||
}
|
||||
|
||||
/// The instance told `initialized`, ready or not.
|
||||
pub fn instance(&self) -> Option<Running> {
|
||||
self.running().or_else(|| self.initializing.lock().clone())
|
||||
@@ -346,6 +369,13 @@ impl Server {
|
||||
self.running().filter(|r| r.generation == generation)
|
||||
}
|
||||
|
||||
/// Instance `generation` is starting or running: not stopped, crashed or replaced.
|
||||
/// Work it reports before it is ready (a Gradle import answers `initialize` late) still counts.
|
||||
pub fn is_live(&self, generation: u64) -> bool {
|
||||
let st = self.state.lock();
|
||||
st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_))
|
||||
}
|
||||
|
||||
/// Waits through startup or a planned restart; returns the instance once ready.
|
||||
pub async fn wait_ready(&self, timeout: Duration) -> Option<Running> {
|
||||
let mut rx = self.phase.subscribe();
|
||||
@@ -436,10 +466,12 @@ impl Server {
|
||||
}
|
||||
let tail = self.stderr_tail(5);
|
||||
let cfg = self.effective_config();
|
||||
let mut message = match (code, &cfg.connect) {
|
||||
(Some(c), _) => format!("{} exited with code {c}", self.name),
|
||||
(None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", self.name),
|
||||
(None, None) => format!("{} terminated unexpectedly", self.name),
|
||||
let start_error = self.start_error.lock().take().filter(|(g, _)| *g == generation).map(|(_, r)| r);
|
||||
let mut message = match (&start_error, code, &cfg.connect) {
|
||||
(Some(reason), _, _) => format!("{} failed to start: {reason}", self.name),
|
||||
(None, Some(c), _) => format!("{} exited with code {c}", self.name),
|
||||
(None, None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", self.name),
|
||||
(None, None, None) => format!("{} terminated unexpectedly", self.name),
|
||||
};
|
||||
if !tail.is_empty() {
|
||||
message.push_str(":\n");
|
||||
@@ -472,6 +504,7 @@ impl Server {
|
||||
/// Records a crash; returns the backoff before restarting, or `None` once
|
||||
/// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`]
|
||||
/// (a server that crashes on every start, however slowly, is not restarted forever).
|
||||
/// An instance that never became ready has run for no time at all.
|
||||
pub fn record_crash(&self) -> Option<Duration> {
|
||||
let uptime = self.started.lock().map(|t| t.elapsed()).unwrap_or_default();
|
||||
let mut crashes = self.crashes.lock();
|
||||
@@ -507,7 +540,13 @@ impl Server {
|
||||
return;
|
||||
}
|
||||
*cur = (status, message.clone());
|
||||
st.events.push_back(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
|
||||
st.events.push_back(IdeEvent::LanguageServer {
|
||||
server: self.id,
|
||||
name: self.name.clone(),
|
||||
label: self.display_name(),
|
||||
status,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
fn queue_cleanup(&self, st: &mut State) {
|
||||
@@ -1028,6 +1067,38 @@ mod tests {
|
||||
assert!(s.is_busy(), "a reporting task keeps the server busy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_time_spent_ready_counts_as_uptime() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
for n in 1..=MAX_RESTARTS + 1 {
|
||||
// Never ready: crashes after a slow start count however long the start took.
|
||||
let generation = s.begin_start().unwrap();
|
||||
assert!(s.started.lock().is_none());
|
||||
s.set_start_error(generation, "it did not answer initialize within 300 s".into());
|
||||
let delay = s.on_closed(generation, &sink, None).unwrap();
|
||||
assert_eq!(delay.is_some(), n <= MAX_RESTARTS, "crash {n}");
|
||||
let message = s.status_message().unwrap();
|
||||
assert!(
|
||||
message.starts_with(&format!("{} failed to start: it did not answer initialize", s.name)),
|
||||
"{message}"
|
||||
);
|
||||
s.state.lock().conn = Conn::Idle;
|
||||
}
|
||||
assert!(matches!(s.state.lock().conn, Conn::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_is_live_from_its_start_until_it_is_retired() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
assert!(s.is_live(generation), "still starting: its progress (an import) is being watched");
|
||||
assert!(!s.is_live(generation + 1));
|
||||
assert!(s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, None));
|
||||
assert!(!s.is_live(generation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() {
|
||||
let s = server();
|
||||
|
||||
@@ -341,6 +341,10 @@ pub struct ServerInfo {
|
||||
pub status: ServerStatus,
|
||||
/// OS process of the running instance (for memory attribution).
|
||||
pub pid: Option<u32>,
|
||||
/// What the user sees as the server when another program serves in its place.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Read-only contents a language server provides for a non-`file:` URI
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
|
||||
if matches!(key.as_str(), "data" | "arguments") {
|
||||
continue;
|
||||
}
|
||||
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
|
||||
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri" | "target")
|
||||
&& let Some(uri) = value.as_str().and_then(escaped_uri)
|
||||
{
|
||||
*value = uri.into();
|
||||
@@ -247,6 +247,22 @@ mod tests {
|
||||
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
|
||||
}
|
||||
|
||||
/// `DocumentLink.target` is a URI too: one unescaped link fails the whole `documentLink` response.
|
||||
#[test]
|
||||
fn document_link_targets_are_escaped() {
|
||||
let range = serde_json::json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } });
|
||||
let mut links = serde_json::json!([
|
||||
{ "range": range, "target": "file:///D:/My Game/项目/a.xsd" },
|
||||
{ "range": range, "target": "https://example.com/a b" }
|
||||
]);
|
||||
assert!(serde_json::from_value::<Vec<lsp_types::DocumentLink>>(links.clone()).is_err());
|
||||
normalize_uris(&mut links);
|
||||
assert_eq!(links[0]["target"], "file:///D:/My%20Game/%E9%A1%B9%E7%9B%AE/a.xsd");
|
||||
assert_eq!(links[1]["target"], "https://example.com/a b", "other schemes are not touched");
|
||||
links[1]["target"] = "https://example.com/ab".into();
|
||||
assert_eq!(serde_json::from_value::<Vec<lsp_types::DocumentLink>>(links).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// #3089: jdtls's Introduce Variable edit for a project under a Chinese path names the file
|
||||
/// with the characters unescaped, and the resolved code action failed to deserialize.
|
||||
#[test]
|
||||
|
||||
@@ -146,6 +146,9 @@ impl Handler for MockHandler {
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
if let Some(error) = s.errors.lock().get("initialize").cloned() {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } }))
|
||||
}
|
||||
"shutdown" => Ok(Value::Null),
|
||||
@@ -268,6 +271,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
@@ -292,37 +296,48 @@ fn harness_configured(
|
||||
servers: &[(&str, Value)],
|
||||
adjust: impl Fn(&mut LanguageServerConfig),
|
||||
) -> Harness {
|
||||
harness_languages(&[(lang_id, ext, servers)], adjust)
|
||||
}
|
||||
|
||||
/// A language as `(id, extension, servers)`.
|
||||
type MockLanguage<'a> = (&'a str, &'a str, &'a [(&'a str, Value)]);
|
||||
|
||||
/// Several languages; documents open as the first.
|
||||
fn harness_languages(languages: &[MockLanguage<'_>], adjust: impl Fn(&mut LanguageServerConfig)) -> Harness {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Edits and references report canonical paths.
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let lang = LanguageConfig {
|
||||
id: LanguageId::new(lang_id),
|
||||
name: "Mock".into(),
|
||||
extensions: vec![ext.into()],
|
||||
filenames: vec![],
|
||||
first_line: None,
|
||||
line_comment: None,
|
||||
block_comment: None,
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
framework_files: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
.map(|(n, _)| {
|
||||
let mut cfg = server_cfg(n);
|
||||
adjust(&mut cfg);
|
||||
cfg
|
||||
})
|
||||
.collect(),
|
||||
debugger: None,
|
||||
};
|
||||
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
|
||||
let configs = languages
|
||||
.iter()
|
||||
.map(|(lang_id, ext, servers)| LanguageConfig {
|
||||
id: LanguageId::new(*lang_id),
|
||||
name: "Mock".into(),
|
||||
extensions: vec![ext.to_string()],
|
||||
filenames: vec![],
|
||||
first_line: None,
|
||||
line_comment: None,
|
||||
block_comment: None,
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
framework_files: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
.map(|(n, _)| {
|
||||
let mut cfg = server_cfg(n);
|
||||
adjust(&mut cfg);
|
||||
cfg
|
||||
})
|
||||
.collect(),
|
||||
debugger: None,
|
||||
})
|
||||
.collect();
|
||||
let registry = Arc::new(LanguageRegistry::from_configs(configs));
|
||||
let launcher = Arc::new(MockLauncher::default());
|
||||
for (name, caps) in servers {
|
||||
for (name, caps) in languages.iter().flat_map(|(_, _, servers)| servers.iter()) {
|
||||
*launcher.state(name).caps.lock() = caps.clone();
|
||||
}
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -331,7 +346,7 @@ fn harness_configured(
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let mgr = LspManager::with_launcher(root.clone(), registry, sink, launcher.clone());
|
||||
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(lang_id) }
|
||||
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(languages[0].0) }
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
@@ -2072,6 +2087,29 @@ async fn crash_restarts_and_reopens_documents() {
|
||||
assert_eq!(hover.contents.value, "hi");
|
||||
}
|
||||
|
||||
/// A restart during a long start (a project import) returns when the instance is up and
|
||||
/// running, and does not hold up `reconfigure` and `shutdown_all` meanwhile.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn restart_does_not_hold_the_reconfigure_lock_while_the_server_starts() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*s.initialize_gate.lock() = Some(gate.clone());
|
||||
let id = h.mgr.servers()[0].id;
|
||||
let restart = tokio::spawn({
|
||||
let mgr = h.mgr.clone();
|
||||
async move { mgr.restart(id).await }
|
||||
});
|
||||
eventually(|| gate.entered()).await;
|
||||
let reconfigured = tokio::time::timeout(Duration::from_secs(2), h.mgr.reconfigure()).await;
|
||||
gate.open();
|
||||
reconfigured.expect("a restart still waiting for the server to start must not block reconfigure");
|
||||
restart.await.unwrap().unwrap();
|
||||
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -2099,6 +2137,118 @@ async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
|
||||
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
|
||||
}
|
||||
|
||||
/// One malformed diagnostic costs only itself, not the file's other problems.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_malformed_diagnostic_does_not_drop_the_others() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (_, path) = h.open("a.mk", "one");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
let range = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":1}});
|
||||
h.launcher
|
||||
.state("mock")
|
||||
.peer()
|
||||
.notify(
|
||||
"textDocument/publishDiagnostics",
|
||||
json!({ "uri": uri, "diagnostics": [
|
||||
{ "range": range, "message": "first" },
|
||||
{ "message": "no range" },
|
||||
{ "range": range, "message": 7 },
|
||||
{ "range": range, "message": "last" }] }),
|
||||
)
|
||||
.unwrap();
|
||||
eventually(|| {
|
||||
h.events.lock().iter().any(|e| match e {
|
||||
IdeEvent::Diagnostics { items, .. } => {
|
||||
items.iter().map(|d| d.message.as_str()).collect::<Vec<_>>() == ["first", "last"]
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The `initialize` error is the crash report's reason, not "terminated unexpectedly", and
|
||||
/// a server that never gets past `initialize` gives up like any other crash loop.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_initialize_is_reported_with_its_reason_and_gives_up() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let s = h.launcher.state("mock");
|
||||
s.errors.lock().insert("initialize".into(), (-32603, "no workspace".into()));
|
||||
h.open("a.mk", "x");
|
||||
let count = |status| h.statuses("mock").iter().filter(|s| **s == status).count();
|
||||
eventually(|| count(ServerStatus::Crashed) == 4).await;
|
||||
let messages: Vec<String> = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
messages[0].contains("failed to start: initialize failed (server error -32603: no workspace)"),
|
||||
"{messages:?}"
|
||||
);
|
||||
assert!(messages[0].ends_with("Restarting…"));
|
||||
assert!(messages[3].ends_with("restart it manually."), "{messages:?}");
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 4, "initial start + 3 restarts");
|
||||
}
|
||||
|
||||
/// The client tells servers it retries semantic tokens, inlay hints and code lens that they
|
||||
/// withdrew with `ContentModified` (`staleRequestSupport`), so it must.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn withdrawn_editor_decoration_requests_are_asked_again() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "let x = 1");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let range = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":1}});
|
||||
let cases = [
|
||||
("textDocument/semanticTokens/full", json!({ "data": [0, 0, 1, 0, 0] })),
|
||||
("textDocument/inlayHint", json!([{ "position": {"line":0,"character":0}, "label": "x" }])),
|
||||
("textDocument/codeLens", json!([{ "range": range, "command": { "title": "t", "command": "c" } }])),
|
||||
];
|
||||
for (method, answer) in cases {
|
||||
s.errors.lock().insert(method.into(), (-32801, "content modified".into()));
|
||||
let mgr = h.mgr.clone();
|
||||
let request = tokio::spawn(async move {
|
||||
match method {
|
||||
"textDocument/semanticTokens/full" => mgr.semantic_tokens(doc).await.map(|r| r.is_some()),
|
||||
"textDocument/inlayHint" => {
|
||||
mgr.inlay_hints(doc, Range::new(Pos::new(0, 0), Pos::new(0, 9))).await.map(|v| v.len() == 1)
|
||||
}
|
||||
_ => mgr.code_lens(doc).await.map(|v| v.len() == 1),
|
||||
}
|
||||
});
|
||||
eventually(|| !s.requested(method).is_empty()).await;
|
||||
s.errors.lock().remove(method);
|
||||
s.respond(method, answer);
|
||||
let answered = tokio::time::timeout(Duration::from_secs(5), request).await.unwrap().unwrap();
|
||||
assert!(answered.unwrap_or(false), "{method} was not asked again");
|
||||
assert_eq!(s.requested(method).len(), 2, "{method}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A server disabled in settings while its crash backoff is still sleeping stays gone: the
|
||||
/// pending restart must not bring it back as a process nothing lists or stops.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_server_disabled_during_its_crash_backoff_does_not_restart() {
|
||||
let h = harness(&[("mock", incremental_caps()), ("other", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
s.peer().kill();
|
||||
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "other").cloned().collect()));
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| h.removed("mock")).await;
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart after the server was dropped");
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
@@ -2807,6 +2957,63 @@ async fn library_classes_in_jars_open_as_their_source_or_decompiled() {
|
||||
assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn library_classes_without_sources_open_decompiled_by_a_running_jdtls() {
|
||||
use std::io::Write;
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
let jar = dunce::canonicalize(repo.path()).unwrap().join("acme/lib/1.0/lib-1.0.jar");
|
||||
std::fs::create_dir_all(jar.parent().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&jar).unwrap());
|
||||
zip.start_file("acme/Thing.class", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(b"\0").unwrap();
|
||||
zip.finish().unwrap();
|
||||
let class = format!("jar://{}!/acme/Thing.class", uri_of(&jar).trim_start_matches("file://"));
|
||||
let escaped = jar.to_string_lossy().replace('/', "%5C/");
|
||||
let jdt = format!("jdt://contents/lib-1.0.jar/acme/Thing.class?=app/{escaped}%3Cacme%28Thing.class");
|
||||
let decompiled =
|
||||
"package acme;\n\npublic class Thing {\n public void run() {\n System.out.println();\n }\n}\n";
|
||||
|
||||
let mut kotlin = incremental_caps();
|
||||
kotlin["executeCommandProvider"] = json!({ "commands": ["decompile"] });
|
||||
let mut java = incremental_caps();
|
||||
java["workspaceSymbolProvider"] = json!(true);
|
||||
let h =
|
||||
harness_languages(&[("kotlin", "kt", &[("kotlin-lsp", kotlin)]), ("java", "java", &[("jdtls", java)])], |_| {});
|
||||
let (doc, _) = h.open("Main.kt", "val thing = Thing()\n");
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
let k = h.launcher.state("kotlin-lsp");
|
||||
k.respond(
|
||||
"textDocument/definition",
|
||||
json!({ "uri": class, "range": {"start":{"line":0,"character":6},"end":{"line":0,"character":11}} }),
|
||||
);
|
||||
let r = json!({"start":{"line":2,"character":13},"end":{"line":2,"character":18}});
|
||||
|
||||
// jdtls not running: the Kotlin server's own target and decompiler.
|
||||
let defs = h.mgr.goto(doc, Pos::new(0, 14), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(defs[0].uri.as_deref(), Some(class.as_str()));
|
||||
|
||||
let path = h.root.join("Sample.java");
|
||||
std::fs::write(&path, "class Sample {}").unwrap();
|
||||
h.mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class Sample {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let j = h.launcher.state("jdtls");
|
||||
j.respond(
|
||||
"workspace/symbol",
|
||||
json!([{ "name": "Thing", "kind": 5, "containerName": "acme", "location": { "uri": jdt, "range": r } }]),
|
||||
);
|
||||
j.respond("java/classFileContents", json!(decompiled));
|
||||
|
||||
let defs = h.mgr.goto(doc, Pos::new(0, 14), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(
|
||||
defs,
|
||||
vec![ide_core::Location { path: jdt.clone().into(), range: rng(2, 13, 2, 18), uri: Some(jdt.clone()) }]
|
||||
);
|
||||
assert_eq!(j.requested("workspace/symbol")[0]["query"], "acme.Thing");
|
||||
let vd = h.mgr.virtual_document(&jdt).await.unwrap();
|
||||
assert_eq!(vd.text, decompiled);
|
||||
assert!(k.requested("workspace/executeCommand").is_empty(), "the Kotlin stub is not asked for");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn alternative_java_server_uses_lsp_without_jdt_commands() {
|
||||
let h = harness_for("java", "java", &[("kmp-lsp", incremental_caps())], &[]);
|
||||
@@ -2997,6 +3204,49 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
}
|
||||
|
||||
/// A server that is installed but cannot run (crashed for good, or its configuration
|
||||
/// can't launch it) hides its fallbacks no longer than one that is missing.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_take_over_from_a_preferred_one_that_crashed_for_good() {
|
||||
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
|
||||
let (doc, path) = h.open("a.mk", "x");
|
||||
let main = h.launcher.state("main");
|
||||
let count = |status| h.statuses("main").iter().filter(|s| **s == status).count();
|
||||
for n in 1..=4 {
|
||||
eventually(|| count(ServerStatus::Ready) == n).await;
|
||||
// Restarting after a crash is not giving up: the fallback stays away.
|
||||
assert!(h.statuses("alt").is_empty(), "alt started after crash {}", n - 1);
|
||||
main.peer().kill();
|
||||
eventually(|| count(ServerStatus::Crashed) == n).await;
|
||||
}
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
|
||||
// Restarting the preferred one by hand gives the documents back to it.
|
||||
let id = h.mgr.servers().into_iter().find(|s| s.name == "main").unwrap().id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
eventually(|| h.removed("alt")).await;
|
||||
assert_eq!(h.mgr.servers().iter().filter(|s| s.name == "alt").count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_take_over_from_a_preferred_one_that_cannot_be_launched() {
|
||||
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| {
|
||||
if cfg.name == "main" {
|
||||
cfg.launch_error = Some("needs JDK 21".into());
|
||||
}
|
||||
}));
|
||||
let (doc, path) = h.open("a.mk", "x");
|
||||
h.wait_status("main", ServerStatus::Misconfigured).await;
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
let uri = uri_of(&path);
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_server_requiring_a_root_marker_serves_only_files_below_one() {
|
||||
// `framework` next to `main` (one instance for the whole project), like the
|
||||
|
||||
@@ -426,6 +426,7 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
};
|
||||
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
|
||||
@@ -698,6 +699,7 @@ async fn typescript_language_server_end_to_end() {
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
};
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
|
||||
@@ -750,6 +752,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,24 @@ pub struct PluginRunConfiguration {
|
||||
pub cwd: Option<String>,
|
||||
pub test: bool,
|
||||
pub framework: Option<String>,
|
||||
/// API 1.9: variables added to the run's environment.
|
||||
#[serde(default)]
|
||||
pub env: Vec<(String, String)>,
|
||||
/// API 1.9: how Debug starts the configuration.
|
||||
#[serde(default)]
|
||||
pub debugger: Option<PluginDebugger>,
|
||||
}
|
||||
|
||||
/// How Debug starts a plugin configuration (API 1.9 `launch-configurations.debugger`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum PluginDebugger {
|
||||
/// `program` under the debugger of this language id.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Launch { language: String },
|
||||
/// Run, then attach to the first matching process the run starts.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AttachChild { kind: String, command_contains: Option<String> },
|
||||
}
|
||||
|
||||
/// RunExited data exchanged with the IDE.
|
||||
|
||||
@@ -36,6 +36,7 @@ use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDea
|
||||
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
|
||||
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
use crate::wit_host::launch_configurations_bindings::exports::openidea::plugin::launch_configurations as wit_launch;
|
||||
use crate::wit_host::run_configurations_bindings::exports::openidea::plugin::run_configurations as wit_configs;
|
||||
use crate::wit_host::test_results_bindings::exports::openidea::plugin::test_results as wit_tests;
|
||||
use bindings::openidea::plugin::events as wit_events;
|
||||
@@ -55,7 +56,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.8";
|
||||
pub const API_VERSION: &str = "1.9";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -1008,6 +1009,7 @@ struct Exports {
|
||||
inlay: Option<wit_inlay::Guest>,
|
||||
endpoints: Option<wit_endpoints::Guest>,
|
||||
run_configurations: Option<wit_configs::Guest>,
|
||||
launch_configurations: Option<wit_launch::Guest>,
|
||||
test_results: Option<wit_tests::Guest>,
|
||||
mcp: Option<crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::Guest>,
|
||||
}
|
||||
@@ -1147,6 +1149,7 @@ impl Runtime {
|
||||
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
|
||||
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
|
||||
let configs_indices = wit_configs::GuestIndices::new(&pre).ok();
|
||||
let launch_indices = wit_launch::GuestIndices::new(&pre).ok();
|
||||
let tests_indices = wit_tests::GuestIndices::new(&pre).ok();
|
||||
let validation_indices = wit_rtv::GuestIndices::new(&pre).ok();
|
||||
let lf_indices = wit_lf::GuestIndices::new(&pre).ok();
|
||||
@@ -1157,6 +1160,8 @@ impl Runtime {
|
||||
let mcp = mcp_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let run_configurations =
|
||||
configs_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let launch_configurations =
|
||||
launch_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let test_results = tests_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let language_features = lf_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let injections = inj_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
@@ -1230,6 +1235,7 @@ impl Runtime {
|
||||
inlay,
|
||||
endpoints,
|
||||
run_configurations,
|
||||
launch_configurations,
|
||||
test_results,
|
||||
mcp,
|
||||
file_actions,
|
||||
@@ -1269,34 +1275,67 @@ impl Runtime {
|
||||
.map(|r| r.map_err(|message| clip(message, 4096)))
|
||||
}
|
||||
|
||||
/// Complete configurations from the optional API 1.4 export.
|
||||
/// Complete configurations from the optional API 1.9 export, else the API 1.4 one.
|
||||
pub fn run_configurations(
|
||||
&mut self,
|
||||
root: String,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::PluginRunConfiguration>, CallError> {
|
||||
if self.exports.run_configurations.is_none() {
|
||||
return Err(CallError::missing_export("run-configurations"));
|
||||
}
|
||||
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
|
||||
let items = self.call(invocation, timeout, |e, store| {
|
||||
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
|
||||
})?;
|
||||
if items.len() > 1000 {
|
||||
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
|
||||
}
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|c| crate::PluginRunConfiguration {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
program: c.program,
|
||||
args: c.args,
|
||||
cwd: c.cwd,
|
||||
test: c.test,
|
||||
framework: c.framework,
|
||||
})
|
||||
.collect())
|
||||
let items: Vec<crate::PluginRunConfiguration> = if self.exports.launch_configurations.is_some() {
|
||||
let items = self.call(invocation, timeout, |e, store| {
|
||||
e.launch_configurations.as_ref().unwrap().call_configurations(store, &root)
|
||||
})?;
|
||||
if items.len() > 1000 {
|
||||
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
|
||||
}
|
||||
items
|
||||
.into_iter()
|
||||
.map(|c| crate::PluginRunConfiguration {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
program: c.program,
|
||||
args: c.args,
|
||||
cwd: c.cwd,
|
||||
test: c.test,
|
||||
framework: c.framework,
|
||||
env: c.env,
|
||||
debugger: match c.debugger {
|
||||
Some(wit_launch::Debugger::Launch(language)) => {
|
||||
Some(crate::PluginDebugger::Launch { language })
|
||||
}
|
||||
Some(wit_launch::Debugger::AttachChild(a)) => Some(crate::PluginDebugger::AttachChild {
|
||||
kind: a.kind,
|
||||
command_contains: a.command_contains,
|
||||
}),
|
||||
Some(wit_launch::Debugger::Other(_)) | None => None,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
} else if self.exports.run_configurations.is_some() {
|
||||
let items = self.call(invocation, timeout, |e, store| {
|
||||
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
|
||||
})?;
|
||||
if items.len() > 1000 {
|
||||
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
|
||||
}
|
||||
items
|
||||
.into_iter()
|
||||
.map(|c| crate::PluginRunConfiguration {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
program: c.program,
|
||||
args: c.args,
|
||||
cwd: c.cwd,
|
||||
test: c.test,
|
||||
framework: c.framework,
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
return Err(CallError::missing_export("run-configurations"));
|
||||
};
|
||||
Ok(items)
|
||||
}
|
||||
/// Selects tests under the same budget as the test parser.
|
||||
pub fn select_tests(
|
||||
|
||||
@@ -2254,6 +2254,11 @@ impl bindings::openidea::plugin::run::Host for StoreData {
|
||||
pub(crate) mod run_configurations_bindings {
|
||||
wasmtime::component::bindgen!({ world: "configurations-provider", path: "../../wit" });
|
||||
}
|
||||
pub(crate) mod launch_configurations_bindings {
|
||||
wasmtime::component::bindgen!({ world: "launch-configurations-provider", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
pub(crate) mod test_results_bindings {
|
||||
wasmtime::component::bindgen!({ world: "test-results-provider", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
|
||||
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.8.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
|
||||
let host = v("1.9.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.9.0", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.9") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
let newer = check_api(&v("1.10.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.10") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.8 SDK"), "{older}");
|
||||
assert!(older.contains("rebuild it with the 1.9 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
|
||||
a3d2511f30277245a8cb9673c4d8a948a8a025b8b9149d5015bed5137a98ccdd
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
|
||||
dad3edad99ad85234aa612bbc1f3a66e50361189e54038b91674703815f049cc
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
|
||||
918af7d4d8bc302621b9b68070a56930813b1fc2ba235d89de4e56c53eaba5bd
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
|
||||
7a140c2255a3d251a5ab9079bec953cccc4408727b067b49da47cf8fb27d8cb8
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
|
||||
ef41934bd0b67f76a17ef321b44c172496314c9fea6b46b4cd2f1d0f4b96907d
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
|
||||
c6dc1ae00d9f3d936f4bf5ff110915050cb503ac06982bdc48e967deac84a913
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
|
||||
25a782556dd6bbf8ed1ad7b50b9b55685f074d80220af08f3bd9cb12cf07f74e
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "execution"
|
||||
name = "Plugin execution tests"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.5"
|
||||
engines.openidea = "^1.9"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
[capabilities]
|
||||
|
||||
+32
-12
@@ -114,17 +114,37 @@ impl Plugin for Execution {
|
||||
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
impl RunConfigurations for Execution {
|
||||
fn configurations(&mut self, _: &str) -> Vec<PluginRunConfiguration> {
|
||||
vec![PluginRunConfiguration {
|
||||
id: "dynamic".into(),
|
||||
name: "Dynamic tests".into(),
|
||||
program: "execution-tool".into(),
|
||||
args: vec!["test".into()],
|
||||
test: true,
|
||||
framework: Some("plugin".into()),
|
||||
..Default::default()
|
||||
}]
|
||||
impl LaunchConfigurations for Execution {
|
||||
fn launch_configurations(&mut self, _: &str) -> Vec<PluginLaunchConfiguration> {
|
||||
vec![
|
||||
PluginLaunchConfiguration {
|
||||
config: PluginRunConfiguration {
|
||||
id: "dynamic".into(),
|
||||
name: "Dynamic tests".into(),
|
||||
program: "execution-tool".into(),
|
||||
args: vec!["test".into()],
|
||||
test: true,
|
||||
framework: Some("plugin".into()),
|
||||
..Default::default()
|
||||
},
|
||||
env: vec![("OPENIDEA_EXECUTION_FIXTURE".into(), "1".into())],
|
||||
debugger: Some(Debugger::Launch { language: "python".into() }),
|
||||
},
|
||||
PluginLaunchConfiguration {
|
||||
config: PluginRunConfiguration {
|
||||
id: "worker".into(),
|
||||
name: "Worker".into(),
|
||||
program: "execution-tool".into(),
|
||||
args: vec!["start".into()],
|
||||
..Default::default()
|
||||
},
|
||||
env: Vec::new(),
|
||||
debugger: Some(Debugger::AttachChild {
|
||||
kind: "dotnet".into(),
|
||||
command_contains: Some("Worker.dll".into()),
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
impl TestResults for Execution {
|
||||
@@ -181,4 +201,4 @@ impl RunTargetValidation for Execution {
|
||||
}
|
||||
}
|
||||
}
|
||||
register_plugin!(Execution, run_configurations, test_results, run_target_validation);
|
||||
register_plugin!(Execution, launch_configurations, test_results, run_target_validation);
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
|
||||
f58b5f68d56611c3d15f69ff8e73e6e65d84e55d14c9bae00ed50b346460adfa
|
||||
Бінарний файл не відображається.
Loaded 100 of 549 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача