Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai a738209cbe fix(editor): Sort Lines orders by character code
Sort Lines used a locale-aware collator, which interleaves upper- and
lowercase letters. It now compares UTF-16 code units, the order IDEA's
Sort Lines uses (String.compareTo) and LC_ALL=C sort gives.

Closes #3516
2026-11-15 22:46:55 +08:00
455 змінених файлів з 1758 додано та 6570 видалено

No files matched your search

-1
Переглянути файл
@@ -42,7 +42,6 @@ Visual style: dark, low-contrast greys, one cool accent
shortcuts live in `src/platform/keymaps/`.
- Styling: CSS Modules + tokens only (no hex literals in components). Inline SVG
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
`gap` uses the `--space-*` scale (`src/styles/tokens.css`, 4px grid) where a value is on it.
- User-visible text goes through the text catalog: add every new string to both
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
Other locales may lack entries; their UI shows the English text until a translator adds it.
+8 -8
Переглянути файл
@@ -38,14 +38,14 @@ Editör, kod analizi, hata ayıklayıcı (debugger), Git, terminal, veritabanı
[freqtrade](https://github.com/freqtrade/freqtrade) projesini (Python, ~770 dosya)
önbellekler hazırken açma, 5 çalıştırmanın ortancası:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| ---------------------------- | ----------------------- | ---------------------- |
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
| Kapanma | **0,3 sn** | 5,9 sn |
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| ---------------------------------- | --------------------- | ---------------------- |
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
| Kapanma | **0,3 sn** | 5,9 sn |
`main` dalından derlenmiş bir OpenIDEA sürümü ve varsayılan ayar ve eklentilerle IDEA;
Windows 11, 32 çekirdekli AMD, 62 GB RAM.
+17 -14
Переглянути файл
@@ -10,14 +10,12 @@ use crate::provider::ProviderSpec;
use crate::{Shared, client_info};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// Opens `session/new`, reads `configOptions`, and records the `thought_level` choices of the
/// session's model: they describe that model only, and other models may offer different ones.
/// 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 {
@@ -64,17 +62,21 @@ async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool
.map_err(|e| e.to_string())?;
let mut options = config::options_of(&new);
config::add_client_options(&launch.id, &mut options);
let model = options.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone());
let levels = match (model, levels_for_model_from_slice(&options)) {
(Some(model), Some(effort)) => BTreeMap::from([(model, effort)]),
_ => BTreeMap::new(),
let model_option = options.iter().find(|o| o.category.as_deref() == Some("model"));
let effort = levels_for_model_from_slice(&options);
let levels: Vec<(String, Vec<AgentConfigOption>)> = match (model_option, effort) {
(Some(model), Some(effort)) if !model.values.is_empty() => {
model.values.iter().map(|v| (v.value.clone(), effort.clone())).collect()
}
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
};
let commands = updates
.commands
.lock()
.clone()
.or_else(|| new.get("availableCommands").map(|_| config::commands_of(&new, &launch.id)));
Ok::<_, String>(Discovered { config: options, commands, levels })
Ok::<_, String>(Discovered { config: options, commands, levels: levels.into_iter().collect() })
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
@@ -106,17 +108,18 @@ impl ide_jsonrpc::Handler for DiscoveryUpdates {
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
o.category.as_deref() == Some("thought_level")
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
}
fn levels_for_model_from_slice(options: &[AgentConfigOption]) -> Option<Vec<AgentConfigOption>> {
let levels: Vec<_> = options
.iter()
.filter(|o| config::is_per_model_option(o))
.filter(|o| is_thought_level(o))
.cloned()
.map(|mut o| {
if config::is_context_option(&o.id) {
if o.category.is_none() {
o.category = Some("model_config".into());
}
} else if o.category.as_deref() != Some("thought_level") {
if o.category.as_deref() != Some("thought_level") {
o.category = Some("thought_level".into());
}
o
-9
Переглянути файл
@@ -58,8 +58,6 @@ fn client_capabilities() -> Value {
"terminal": false,
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
"subagents": {},
// Forms the agent asks the user to fill in (`elicitation/create`); not URL mode.
"elicitation": { "form": {} },
// Cursor exposes model parameters (e.g. Composer `fast`) as separate config options
// only when the client advertises this flag (undocumented Cursor ACP extension).
"_meta": { "parameterizedModelPicker": true },
@@ -322,13 +320,6 @@ impl Handler for Dialect {
}
self.inner.on_request(method, params)
}
fn on_request_raw(&self, method: &str, params: Value, raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
if self.v2() && method == "session/request_permission" {
return self.on_request_with_data(method, params);
}
self.inner.on_request_raw(method, params, raw)
}
}
/// A v2 permission request with the v1 `toolCall` (its subject's, titled with the request's title).
+1 -5
Переглянути файл
@@ -97,13 +97,10 @@ fn the_negotiating_request_carries_both_versions() {
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
);
// Forms only: URL mode would open pages the agent names.
assert_eq!(p["clientCapabilities"]["elicitation"], json!({ "form": {} }));
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
let mut expected = sent;
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
expected["elicitation"] = p["clientCapabilities"]["elicitation"].clone();
assert_eq!(expected, p["clientCapabilities"]);
}
@@ -171,8 +168,7 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
let fast = cursor.config.iter().find(|o| o.id == "fast").expect("fast option");
assert_eq!(fast.category.as_deref(), Some("model_config"));
assert_eq!(fast.current.as_deref(), Some("false"));
// The effort option belongs to the session's model; m2 may offer other levels.
assert_eq!(cursor.thought_levels.keys().collect::<Vec<_>>(), ["m1"]);
assert!(cursor.thought_levels.contains_key("m1") && cursor.thought_levels.contains_key("m2"));
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
}
+1 -53
Переглянути файл
@@ -56,16 +56,6 @@ pub(crate) struct CatalogData {
pub refreshed_at_ms: Option<f64>,
}
impl CatalogData {
fn model_option(&self) -> Option<&AgentConfigOption> {
self.config.iter().find(|o| o.category.as_deref() == Some("model"))
}
fn lists(&self, model: &str) -> bool {
self.model_option().is_some_and(|o| o.values.iter().any(|v| v.value == model))
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SavedCatalog {
@@ -339,17 +329,11 @@ impl AgentCatalogs {
Ok(found) => {
let mut cache = shared.known.lock();
let known = cache.entry(key.2.clone()).or_default();
let mut catalog = CatalogData {
let catalog = CatalogData {
config: found.config,
thought_levels: found.levels,
refreshed_at_ms: Some(self.epoch_ms()),
};
// Efforts sessions reported for models discovery doesn't read stay while listed.
for (model, levels) in entry.catalog.take().map(|old| old.thought_levels).unwrap_or_default() {
if catalog.lists(&model) && !catalog.thought_levels.contains_key(&model) {
catalog.thought_levels.insert(model, levels);
}
}
known.config = catalog.config.clone();
known.thought_levels = catalog.thought_levels.clone();
if let Some(commands) = found.commands {
@@ -382,42 +366,6 @@ impl AgentCatalogs {
}
}
/// Records the efforts a session reported for a listed `model` other than the one discovery
/// read: ACP discovery opens one session, so it knows only that model's efforts.
pub(crate) fn learn_levels(&self, shared: &Shared, provider: &str, model: &str, levels: Vec<AgentConfigOption>) {
let lacks = |catalog: &CatalogData| {
catalog.lists(model)
&& catalog.model_option().and_then(|o| o.current.as_deref()) != Some(model)
&& catalog.thought_levels.get(model) != Some(&levels)
};
let key = Self::key(shared, provider);
let mut state = self.state.lock();
let mut learned = false;
if let Some(catalog) = state.entries.get_mut(&key).and_then(|e| e.catalog.as_mut()).filter(|c| lacks(c)) {
catalog.thought_levels.insert(model.to_owned(), levels.clone());
learned = true;
}
let mut cache = shared.known.lock();
if let Some(saved) = cache
.get_mut(provider)
.and_then(|known| known.catalogs.get_mut(&shared.root_key()))
.filter(|saved| lacks(&saved.catalog))
{
saved.catalog.thought_levels.insert(model.to_owned(), levels);
learned = true;
}
if !learned {
return;
}
shared.persist_known(&cache);
drop(cache);
let snapshot = state.entries.get(&key).map(|entry| self.snapshot(shared, provider, entry));
drop(state);
if let Some(snapshot) = snapshot {
self.broadcast(shared, &snapshot);
}
}
fn subscribers(&self, shared: &Shared) -> Vec<Arc<Shared>> {
self.state
.lock()
+1 -38
Переглянути файл
@@ -199,8 +199,7 @@ async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_
pending.reply.send(Ok(response(&["retained", "new"], &["medium"]))).unwrap();
let fresh = completed(&mut events).await;
assert_eq!(model_values(&fresh), ["retained", "new"]);
// The session's efforts describe its own model, not "new".
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["retained"]);
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["new", "retained"]);
assert_eq!(fresh.thought_levels["retained"][0].values[0].value, "medium");
assert!(fresh.refreshed_at_ms.is_some() && fresh.retry_at_ms.is_some());
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
@@ -221,42 +220,6 @@ async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_
assert!(!tmp.path().join("store").exists(), "refresh never creates tasks");
}
#[tokio::test]
async fn a_session_adds_the_efforts_of_a_model_discovery_did_not_read() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let mut harness = Harness::new();
let catalogs = Arc::new(AgentCatalogs::default());
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["first", "second"], &["low", "high"]))).unwrap();
completed(&mut events).await;
// A session on "second" reports its own efforts; one on a model the catalog lacks, or on
// "first" with other efforts, changes nothing.
let second = config::options_of(&response(&["second"], &["off", "auto"]));
manager.shared.remember(PROVIDER, Some(&second), None, Some("second"));
manager.shared.remember(PROVIDER, Some(&config::options_of(&response(&["gone"], &["x"]))), None, Some("gone"));
manager.shared.remember(PROVIDER, Some(&config::options_of(&response(&["first"], &["max"]))), None, Some("first"));
let efforts = |catalog: &AgentModelCatalog, model: &str| -> Vec<String> {
catalog.thought_levels[model][0].values.iter().map(|v| v.value.clone()).collect()
};
let catalog = manager.refresh_models(PROVIDER, false).unwrap();
assert_eq!(catalog.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["first", "second"]);
assert_eq!(efforts(&catalog, "first"), ["low", "high"]);
assert_eq!(efforts(&catalog, "second"), ["off", "auto"]);
let restored = make_manager(tmp.path(), Arc::default(), &harness).refresh_models(PROVIDER, false).unwrap();
assert_eq!(efforts(&restored, "second"), ["off", "auto"], "persisted with the project's catalog");
// Discovery again reads "first" only; "second" keeps what its session reported.
advance(&catalogs, 60);
while events.try_recv().is_ok() {}
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&["first", "second"], &["medium"]))).unwrap();
let fresh = completed(&mut events).await;
assert_eq!(efforts(&fresh, "first"), ["medium"]);
assert_eq!(efforts(&fresh, "second"), ["off", "auto"]);
}
#[tokio::test]
async fn legacy_cache_loads_without_claiming_authoritative_freshness() {
let tmp = tempfile::tempdir().unwrap();
+11 -100
Переглянути файл
@@ -20,22 +20,17 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
description: Some("Tool approvals for this Cursor conversation".into()),
category: Some("approval".into()),
current: Some("ask".into()),
default_value: Some("ask".into()),
via: AgentConfigVia::Option,
values: vec![
AgentConfigValue {
value: "ask".into(),
name: "Ask for approval".into(),
description: Some("Show permission requests from Cursor".into()),
cost_category: None,
usage_multiplier: None,
},
AgentConfigValue {
value: "bypass".into(),
name: "Bypass approvals".into(),
description: Some("Automatically approve tool requests in this conversation".into()),
cost_category: None,
usage_multiplier: None,
},
],
});
@@ -123,18 +118,6 @@ pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
out
}
pub(crate) fn is_context_option(id: &str) -> bool {
matches!(id, "context_tier" | "contextTier")
}
/// Whether an option's available values and default belong to the selected model
/// (`thought_level` and model context-tier options) rather than the provider as a whole.
pub(crate) fn is_per_model_option(o: &AgentConfigOption) -> bool {
matches!(o.category.as_deref(), Some("thought_level" | "context_tier"))
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
|| is_context_option(&o.id)
}
/// A `configOptions` array; only select options are kept (the UI shows them as dropdowns).
pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
let Some(list) = options.as_array() else { return Vec::new() };
@@ -150,8 +133,6 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
None => values.extend(value_of(v)),
}
}
let current = str_of(o, "currentValue");
let default_value = str_of(o, "defaultValue").or_else(|| current.clone());
Some(AgentConfigOption {
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
description: str_of(o, "description"),
@@ -162,10 +143,9 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
.then(|| "thought_level".into())
})
.or_else(|| (id == "fast" || is_context_option(&id)).then(|| "model_config".into()))
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
}),
current,
default_value,
current: str_of(o, "currentValue"),
values,
via: AgentConfigVia::Option,
id,
@@ -175,24 +155,9 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
}
/// A `config_option_update`: its options replace the config-option ones; `modes` / `models`
/// stand-ins stay, and initial `default_value`s survive same-model option edits for Reset.
/// stand-ins stay.
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
let mut fresh = parse_options(options);
let old_model = config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.as_deref());
let new_model = fresh.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.as_deref());
let same_model = old_model == new_model;
for f in &mut fresh {
if f.default_value == f.current
&& (same_model || !is_per_model_option(f))
&& let Some(prev_default) = config
.iter()
.find(|p| p.id == f.id)
.and_then(|p| p.default_value.as_ref())
.filter(|def| f.values.iter().any(|v| v.value == **def))
{
f.default_value = Some(prev_default.clone());
}
}
let fresh = parse_options(options);
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
let legacy = std::mem::take(config);
@@ -286,20 +251,16 @@ fn legacy(
Some(AgentConfigValue {
name: str_of(m, "name").unwrap_or_else(|| value.clone()),
description: str_of(m, "description"),
cost_category: None,
usage_multiplier: None,
value,
})
})
.collect();
let current = str_of(v, current);
AgentConfigOption {
id: id.to_owned(),
name: name.to_owned(),
description: None,
category: Some(id.to_owned()),
default_value: current.clone(),
current,
current: str_of(v, current),
values,
via,
}
@@ -307,23 +268,9 @@ fn legacy(
fn value_of(v: &Value) -> Option<AgentConfigValue> {
let value = v.get("value")?.as_str()?.to_owned();
let meta = v.get("_meta");
let cost_category = meta
.and_then(|m| m.get("copilotPriceCategory").or_else(|| m.get("priceCategory")))
.and_then(Value::as_str)
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let usage_multiplier = meta
.and_then(|m| m.get("copilotUsage").or_else(|| m.get("usageMultiplier")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
Some(AgentConfigValue {
name: str_of(v, "name").unwrap_or_else(|| value.clone()),
description: str_of(v, "description"),
cost_category,
usage_multiplier,
value,
})
}
@@ -455,53 +402,17 @@ mod tests {
#[test]
fn effort_and_fast_categories_are_inferred_from_ids() {
let mut c = options_of(&json!({
let c = options_of(&json!({
"configOptions": [
{"id": "model", "type": "select", "currentValue": "gemini-4-carbon",
"options": [{"value": "gemini-4-carbon", "name": "Gemini 4 Carbon",
"description": "Powerful · Medium cost",
"_meta": {"copilotPriceCategory": "Medium", "copilotUsage": "1x"}}]},
{"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
{"id": "contextTier", "type": "select", "currentValue": "default",
"options": [{"value": "default", "name": "200K"}, {"value": "long_context", "name": "1M"}]},
{"id": "effort", "type": "select", "currentValue": "high",
"options": [{"value": "low"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
}));
assert_eq!(c.len(), 4);
assert_eq!(c[0].values[0].cost_category.as_deref(), Some("medium"));
assert_eq!(c[0].values[0].usage_multiplier.as_deref(), Some("1x"));
assert_eq!(c[1].category.as_deref(), Some("thought_level"));
assert_eq!(c[1].default_value.as_deref(), Some("medium"));
assert_eq!(c[2].category.as_deref(), Some("model_config"));
assert!(is_per_model_option(&c[1]) && is_per_model_option(&c[2]) && !is_per_model_option(&c[3]));
// Editing effort on the same model preserves its initial `default_value` for Reset.
merge_update(
&mut c,
&json!([
{"id": "model", "type": "select", "currentValue": "gemini-4-carbon",
"options": [{"value": "gemini-4-carbon"}]},
{"id": "effort", "type": "select", "currentValue": "high",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}
]),
);
let effort = c.iter().find(|o| o.id == "effort").unwrap();
assert_eq!((effort.current.as_deref(), effort.default_value.as_deref()), (Some("high"), Some("medium")));
// Switching models refreshes per-model `default_value` to the new model's default.
merge_update(
&mut c,
&json!([
{"id": "model", "type": "select", "currentValue": "gpt-6-sol",
"options": [{"value": "gemini-4-carbon"}, {"value": "gpt-6-sol"}]},
{"id": "effort", "type": "select", "currentValue": "low",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}
]),
);
let effort = c.iter().find(|o| o.id == "effort").unwrap();
assert_eq!((effort.current.as_deref(), effort.default_value.as_deref()), (Some("low"), Some("low")));
assert_eq!(c.len(), 2);
assert_eq!(c[0].category.as_deref(), Some("thought_level"));
assert_eq!(c[1].category.as_deref(), Some("model_config"));
}
#[test]
-305
Переглянути файл
@@ -1,305 +0,0 @@
//! ACP elicitation in form mode: the agent's restricted JSON Schema as form fields, and the
//! user's answer as the `elicitation/create` response.
use crate::model::{AgentForm, AgentFormField, AgentFormFieldKind, AgentFormOption, AgentPermissionOption};
use serde::Deserialize;
use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
use serde_json::{Map, Value, json};
/// The reply that declines the form; a JSON object of field name to values accepts it, and
/// anything else cancels it.
const DECLINE: &str = "decline";
/// The form `elicitation/create` asks for; `Err` when it asks for something else.
pub(crate) fn form_of(params: &Value) -> Result<AgentForm, String> {
if params.get("mode").and_then(Value::as_str) != Some("form") {
return Err("only form elicitation is supported".to_owned());
}
let schema = params.get("requestedSchema").ok_or("requestedSchema is missing")?;
let required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|names| names.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let properties = schema.get("properties").and_then(Value::as_object).ok_or("requestedSchema has no properties")?;
let fields = properties
.iter()
.map(|(name, property)| field(name, property, required.contains(&name.as_str())))
.collect::<Result<_, _>>()?;
Ok(AgentForm { fields })
}
/// Orders the fields as the request's schema lists them: `Value` keeps object keys sorted, and
/// the agent's order is the one its form reads in.
pub(crate) fn in_schema_order(form: &mut AgentForm, raw: &[u8]) {
#[derive(Deserialize)]
struct Request {
params: Params,
}
#[derive(Deserialize)]
struct Params {
#[serde(rename = "requestedSchema")]
schema: Schema,
}
#[derive(Deserialize)]
struct Schema {
properties: Keys,
}
struct Keys(Vec<String>);
impl<'de> Deserialize<'de> for Keys {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visit;
impl<'de> Visitor<'de> for Visit {
type Value = Keys;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Keys, A::Error> {
let mut keys = Vec::new();
while let Some((key, IgnoredAny)) = map.next_entry::<String, IgnoredAny>()? {
keys.push(key);
}
Ok(Keys(keys))
}
}
deserializer.deserialize_map(Visit)
}
}
let Ok(request) = serde_json::from_slice::<Request>(raw) else { return };
let order = request.params.schema.properties.0;
form.fields.sort_by_key(|field| order.iter().position(|name| *name == field.name).unwrap_or(usize::MAX));
}
fn field(name: &str, property: &Value, required: bool) -> Result<AgentFormField, String> {
let text = |key: &str| property.get(key).and_then(Value::as_str).unwrap_or_default().trim().to_owned();
let number = |key: &str| property.get(key).and_then(Value::as_f64);
let unsupported = || format!("field {name} has an unsupported schema");
let (kind, options, min, max) = match property.get("type").and_then(Value::as_str) {
Some("string") => match choices(property) {
Some(options) => (AgentFormFieldKind::Choice, options, None, None),
None => (AgentFormFieldKind::Text, Vec::new(), number("minLength"), number("maxLength")),
},
Some("number") => (AgentFormFieldKind::Number, Vec::new(), number("minimum"), number("maximum")),
Some("integer") => (AgentFormFieldKind::Integer, Vec::new(), number("minimum"), number("maximum")),
Some("boolean") => (AgentFormFieldKind::Boolean, Vec::new(), None, None),
Some("array") => {
let options = property.get("items").and_then(choices).ok_or_else(unsupported)?;
(AgentFormFieldKind::Choices, options, number("minItems"), number("maxItems"))
}
_ => return Err(unsupported()),
};
let default = match property.get("default") {
Some(Value::Array(values)) => values.iter().filter_map(scalar).collect(),
Some(value) => scalar(value).into_iter().collect(),
None => Vec::new(),
};
let title = text("title");
Ok(AgentFormField {
name: name.to_owned(),
title: if title.is_empty() { name.to_owned() } else { title },
description: text("description"),
required,
kind,
options,
default,
min,
max,
})
}
/// The values of a choice: `enum` (titled by the legacy `enumNames`), or `oneOf` / `anyOf`
/// of `{ const, title }`.
fn choices(schema: &Value) -> Option<Vec<AgentFormOption>> {
let option = |value: &str, label: Option<&str>, description: Option<&str>| AgentFormOption {
value: value.to_owned(),
label: label.filter(|l| !l.trim().is_empty()).unwrap_or(value).to_owned(),
description: description.unwrap_or_default().trim().to_owned(),
};
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
let names = schema.get("enumNames").and_then(Value::as_array);
return Some(
values
.iter()
.enumerate()
.filter_map(|(i, value)| {
Some(option(value.as_str()?, names.and_then(|n| n.get(i)).and_then(Value::as_str), None))
})
.collect(),
);
}
let listed = schema.get("oneOf").or_else(|| schema.get("anyOf"))?.as_array()?;
Some(
listed
.iter()
.filter_map(|o| {
let text = |key: &str| o.get(key).and_then(Value::as_str);
Some(option(text("const")?, text("title"), text("description")))
})
.collect(),
)
}
fn scalar(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Number(number) => Some(number.to_string()),
Value::Bool(flag) => Some(flag.to_string()),
_ => None,
}
}
/// A form is submitted from its own card; this option only names a declined form.
pub(crate) fn options() -> Vec<AgentPermissionOption> {
vec![AgentPermissionOption { id: DECLINE.to_owned(), name: "Decline".to_owned(), kind: "reject_once".to_owned() }]
}
/// The `elicitation/create` response to the user's `reply`, and what the conversation
/// records as chosen.
pub(crate) fn answer(form: &AgentForm, reply: Option<&str>) -> (Value, String) {
let values = match reply {
Some(DECLINE) => return (json!({ "action": "decline" }), "Decline".to_owned()),
Some(reply) => serde_json::from_str::<Map<String, Value>>(reply).ok(),
None => None,
};
let Some(values) = values else { return (json!({ "action": "cancel" }), "Cancelled".to_owned()) };
let mut content = Map::new();
let mut shown = Vec::new();
for field in &form.fields {
let given: Vec<&str> = match values.get(&field.name) {
Some(Value::Array(items)) => items.iter().filter_map(Value::as_str).collect(),
Some(Value::String(item)) => vec![item.as_str()],
_ => Vec::new(),
};
let given: Vec<&str> = given.into_iter().map(str::trim).filter(|v| !v.is_empty()).collect();
let Some(&first) = given.first() else { continue };
let value = match field.kind {
AgentFormFieldKind::Text | AgentFormFieldKind::Choice => json!(first),
AgentFormFieldKind::Number => match first.parse::<f64>() {
Ok(number) => json!(number),
Err(_) => continue,
},
AgentFormFieldKind::Integer => match first.parse::<i64>() {
Ok(number) => json!(number),
Err(_) => continue,
},
AgentFormFieldKind::Boolean => json!(first == "true"),
AgentFormFieldKind::Choices => json!(given),
};
let label = |value: &str| {
field.options.iter().find(|o| o.value == value).map_or_else(|| value.to_owned(), |o| o.label.clone())
};
let text = match field.kind {
AgentFormFieldKind::Boolean => (if first == "true" { "yes" } else { "no" }).to_owned(),
AgentFormFieldKind::Choices => given.iter().map(|v| label(v)).collect::<Vec<_>>().join(", "),
_ => label(first),
};
shown.push(format!("{}: {text}", field.title));
content.insert(field.name.clone(), value);
}
let chosen = if shown.is_empty() { "Submitted".to_owned() } else { shown.join("; ") };
(json!({ "action": "accept", "content": content }), chosen)
}
#[cfg(test)]
mod tests {
use super::*;
/// The form OMP's `ask` tool sends: a choice with a recommended default, then an
/// optional typed answer beside it.
fn ask() -> Value {
json!({
"sessionId": "s", "mode": "form", "message": "Which database?",
"requestedSchema": { "type": "object", "properties": {
"q0": { "type": "string", "title": "Which database?", "description": "Storage",
"oneOf": [{ "const": "pg", "title": "Postgres", "description": "Relational" },
{ "const": "sqlite", "title": "SQLite" }],
"default": "pg" },
"q0__other": { "type": "string", "title": "Other (type your own)" }
} }
})
}
#[test]
fn fields_follow_the_schema_order() {
let raw = br#"{ "jsonrpc": "2.0", "id": 3, "method": "elicitation/create", "params": { "mode": "form",
"requestedSchema": { "type": "object", "properties": {
"strategy": { "type": "string" }, "tests": { "type": "boolean" }, "note": { "type": "string" }
} } } }"#;
let params = serde_json::from_slice::<Value>(raw).unwrap()["params"].take();
let mut form = form_of(&params).unwrap();
in_schema_order(&mut form, raw);
assert_eq!(form.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["strategy", "tests", "note"]);
}
#[test]
fn reads_choices_and_text_fields() {
let form = form_of(&ask()).unwrap();
let [choice, other] = form.fields.as_slice() else { panic!("{form:?}") };
assert_eq!((choice.name.as_str(), choice.kind, choice.required), ("q0", AgentFormFieldKind::Choice, false));
assert_eq!(choice.options[0].label, "Postgres");
assert_eq!(choice.options[0].description, "Relational");
assert_eq!(choice.default, ["pg"]);
assert_eq!((other.kind, other.title.as_str()), (AgentFormFieldKind::Text, "Other (type your own)"));
}
#[test]
fn reads_every_restricted_schema_kind() {
let form = form_of(&json!({ "mode": "form", "requestedSchema": {
"type": "object", "required": ["count"],
"properties": {
"count": { "type": "integer", "minimum": 1, "maximum": 5 },
"ratio": { "type": "number" },
"ok": { "type": "boolean", "default": true },
"size": { "type": "string", "enum": ["s", "l"], "enumNames": ["Small", "Large"] },
"tags": { "type": "array", "items": { "anyOf": [{ "const": "a" }, { "const": "b" }] },
"default": ["b"], "maxItems": 2 }
}
} }))
.unwrap();
let field = |name: &str| form.fields.iter().find(|f| f.name == name).unwrap();
use AgentFormFieldKind::*;
let kinds: Vec<_> = ["count", "ratio", "ok", "size", "tags"].map(|name| field(name).kind).into();
assert_eq!(kinds, [Integer, Number, Boolean, Choice, Choices]);
assert!(field("count").required && !field("ratio").required);
assert_eq!((field("count").min, field("count").max), (Some(1.0), Some(5.0)));
assert_eq!(field("ok").default, ["true"]);
assert_eq!(field("size").options[1].label, "Large");
assert_eq!((field("tags").default.as_slice(), field("tags").max), (["b".to_owned()].as_slice(), Some(2.0)));
}
#[test]
fn url_mode_and_unsupported_fields_are_refused() {
assert!(form_of(&json!({ "mode": "url", "url": "https://example.com" })).is_err());
let nested = json!({ "mode": "form", "requestedSchema": { "type": "object", "properties": {
"address": { "type": "object" }
} } });
assert!(form_of(&nested).is_err());
}
#[test]
fn an_answer_is_typed_by_its_field() {
let form = form_of(&json!({ "mode": "form", "requestedSchema": { "type": "object", "properties": {
"count": { "type": "integer" },
"ok": { "type": "boolean" },
"size": { "type": "string", "oneOf": [{ "const": "s", "title": "Small" }] },
"tags": { "type": "array", "items": { "enum": ["a", "b"] } },
"note": { "type": "string" }
} } }))
.unwrap();
let reply = json!({ "count": ["3"], "ok": ["false"], "size": ["s"], "tags": ["a", "b"], "note": [" "] });
let (response, chosen) = answer(&form, Some(&reply.to_string()));
assert_eq!(
response,
json!({ "action": "accept", "content": { "count": 3, "ok": false, "size": "s", "tags": ["a", "b"] } })
);
assert_eq!(chosen, "count: 3; ok: no; size: Small; tags: a, b");
}
#[test]
fn declining_and_dismissing_send_no_content() {
let form = form_of(&ask()).unwrap();
assert_eq!(answer(&form, Some(DECLINE)), (json!({ "action": "decline" }), "Decline".to_owned()));
assert_eq!(answer(&form, None).0, json!({ "action": "cancel" }));
assert_eq!(answer(&form, Some("cancel")).0, json!({ "action": "cancel" }));
}
}
+34 -124
Переглянути файл
@@ -11,7 +11,6 @@ mod acp;
mod catalogs;
mod config;
mod conversation;
mod elicitation;
mod external;
pub mod mcp;
mod model;
@@ -188,7 +187,7 @@ impl Shared {
fn config_for_new_task(&self, mut config: Vec<AgentConfigOption>, provider: &str) -> Vec<AgentConfigOption> {
// Model-specific options are offered separately, never as provider-wide defaults.
config.retain(|o| !config::is_per_model_option(o));
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config::add_client_options(provider, &mut config);
let hook = self.new_task_approvals.read().clone();
if hook.is_some_and(|ask| ask() == ide_core::settings::NewTaskApprovals::Ask) {
@@ -224,7 +223,6 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
let acp = self.spec(provider).is_some_and(|spec| spec.driver == provider::Driver::Acp);
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
@@ -234,7 +232,8 @@ impl Shared {
&& let Some(config) = config.filter(|c| !c.is_empty())
{
if let Some(model) = model {
let levels: Vec<_> = config.iter().filter(|o| config::is_per_model_option(o)).cloned().collect();
let levels: Vec<_> =
config.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
@@ -245,11 +244,6 @@ impl Shared {
changed = true;
}
}
// Discovery reads one model's options; a session on another model reports that model's.
let learned = model.filter(|_| acp && known.authoritative).zip(config).map(|(model, config)| {
let levels: Vec<_> = config.iter().filter(|o| config::is_per_model_option(o)).cloned().collect();
(model, levels)
});
if let Some(commands) = commands.filter(|c| !c.is_empty())
&& known.commands.get(&self.root_key()).map(Vec::as_slice) != Some(commands)
{
@@ -260,9 +254,6 @@ impl Shared {
self.persist_known(&cache);
}
drop(cache);
if let Some((model, levels)) = learned.filter(|(_, levels)| !levels.is_empty()) {
self.paths.catalogs.learn_levels(self, provider, model, levels);
}
if changed {
self.emit(AgentEvent::Providers);
}
@@ -1009,11 +1000,8 @@ impl AgentManager {
task.emit_task();
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
// The task's copy holds its model's per-model options unless a model switch the agent
// never reported dropped them: caching that copy would erase the model's known levels.
let mut model_options = (target.category.as_deref() != Some("model")
|| previous.as_deref() == Some(value.as_str()))
&& task.state.lock().task.config.iter().any(config::is_per_model_option);
let mut model_options =
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
@@ -1044,7 +1032,7 @@ impl AgentManager {
{
let mut s = task.state.lock();
if !model_options {
s.task.config.retain(|o| !config::is_per_model_option(o));
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value);
@@ -1873,24 +1861,12 @@ impl Task {
let s = self.state.lock();
s.busy && s.task.status == AgentStatus::Running
};
let Some((live, method)) = live
.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images))
.and_then(|l| steer_method(&l.init).map(|m| (l, m)))
else {
let Some(live) = live.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images)) else {
return Err(prompt);
};
let content = self.content(&live, &prompt).await;
let sent: Result<Value, RpcError> =
live.peer.request(method, json!({ "sessionId": live.session, "prompt": content }), None).await;
// `_session/steering` answers with an outcome: only `injected` joined the turn
// (`promptRequired`: no turn is running any more).
let sent = match sent {
Ok(reply) => match reply.get("outcome").and_then(Value::as_str) {
None | Some("injected") => Ok(()),
Some(outcome) => Err(reply.get("reason").and_then(Value::as_str).unwrap_or(outcome).to_owned()),
},
Err(e) => Err(e.to_string()),
};
live.peer.request("_openidea/steer", json!({ "sessionId": live.session, "prompt": content }), None).await;
if let Err(e) = sent {
// The turn ended meanwhile: the message waits for the next one instead.
tracing::debug!("agent task {}: steering failed: {e}", self.id);
@@ -2122,10 +2098,10 @@ impl Task {
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
config::add_client_options(&provider_id, &mut options);
// Changing model can replace the effort / context-tier choices; validate those only afterwards.
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id) {
Some(o) if o.category.as_deref() == Some("model") => 0,
Some(o) if config::is_per_model_option(o) => 2,
// Changing model can replace the effort choices; validate those only afterwards.
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id).and_then(|o| o.category.as_deref()) {
Some("model") => 0,
Some("thought_level") => 2,
_ => 1,
});
let mut model_options = known;
@@ -2138,7 +2114,7 @@ impl Task {
}
continue;
}
if config::is_per_model_option(&o) && !model_options {
if o.category.as_deref() == Some("thought_level") && !model_options {
continue;
}
let already = known && o.current.as_deref() == Some(choice.value.as_str());
@@ -2155,7 +2131,7 @@ impl Task {
model_options = true;
} else if o.category.as_deref() == Some("model") {
if o.current.as_deref() != Some(choice.value.as_str()) {
options.retain(|o| !config::is_per_model_option(o));
options.retain(|o| o.category.as_deref() != Some("thought_level"));
}
model_options = false;
}
@@ -2219,17 +2195,14 @@ impl Task {
if let Some(fresh) = reply.get("configOptions") {
config::merge_update(&mut s.task.config, fresh);
} else if option.category.as_deref() == Some("model") {
s.task.config.retain(|o| !config::is_per_model_option(o));
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) {
o.current = Some(value);
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
// As in `set_config`: a copy without per-model options can't vouch for them.
let model = s.task.model.clone().filter(|_| {
reply.get("configOptions").is_some()
|| (option.category.as_deref() != Some("model")
&& s.task.config.iter().any(config::is_per_model_option))
reply.get("configOptions").is_some() || option.category.as_deref() != Some("model")
});
let (provider, options) = (s.task.provider.clone(), s.task.config.clone());
drop(s);
@@ -2393,7 +2366,12 @@ impl Task {
// first question says what it wants, as the Claude Code adapter titles its own.
let title =
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
let title = self.labelled(params, title);
// A subagent's request is answered in the task's conversation, named after the subagent.
let session = params.get("sessionId").and_then(Value::as_str);
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
Some(label) => format!("{label}: {title}"),
None => title,
};
let options: Vec<AgentPermissionOption> = params
.get("options")
.and_then(Value::as_array)
@@ -2425,7 +2403,6 @@ impl Task {
command: None,
options,
questions: Vec::new(),
form: None,
chosen: Some("Denied: side conversations are read-only".to_owned()),
});
return match reject {
@@ -2452,7 +2429,6 @@ impl Task {
command: shown.clone(),
options,
questions: Vec::new(),
form: None,
chosen: Some(format!("Blocked by policy ({pattern})")),
});
return match reject {
@@ -2473,7 +2449,6 @@ impl Task {
command: shown.clone(),
options,
questions: Vec::new(),
form: None,
chosen: Some(format!("{} (whitelisted)", allow.name)),
});
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
@@ -2489,7 +2464,6 @@ impl Task {
command: shown,
options: options.clone(),
questions,
form: None,
chosen: None,
});
self.permissions.lock().insert(id, tx);
@@ -2516,7 +2490,17 @@ impl Task {
.and_then(|reply| options.iter().find(|option| &option.id == reply))
.map_or_else(|| "Cancelled".to_owned(), |option| option.name.clone()),
};
self.settle(entry, name);
{
let mut s = self.state.lock();
if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) {
*chosen = Some(name);
}
}
self.mark_dirty([(None, entry)]);
let still_asking = !self.permissions.lock().is_empty();
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
self.set_status(AgentStatus::Running, None);
}
// An agent that answers by index reads the answers beside the outcome, keyed by
// question index, with its Submit option chosen; anything less cancels the request.
if by_index {
@@ -2548,57 +2532,6 @@ impl Task {
}
}
/// A request from a subagent is answered in the task's conversation, named after the subagent.
fn labelled(&self, params: &Value, title: String) -> String {
let session = params.get("sessionId").and_then(Value::as_str);
match session.and_then(|s| self.state.lock().conversation.label(s)) {
Some(label) => format!("{label}: {title}"),
None => title,
}
}
/// An agent's form (`elicitation/create`): asked in the conversation like a permission
/// request, and answered with its values, declined or cancelled. Blocks like `ask_permission`.
fn elicit(self: &Arc<Self>, params: &Value, raw: Option<&[u8]>) -> Result<Value, String> {
let mut form = elicitation::form_of(params)?;
if let Some(raw) = raw {
elicitation::in_schema_order(&mut form, raw);
}
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
let message = params.get("message").and_then(Value::as_str).unwrap_or_default().trim().to_owned();
let (tx, rx) = std::sync::mpsc::channel();
let entry = self.push_entry(AgentEntry::Permission {
id,
title: self.labelled(params, message),
command: None,
options: elicitation::options(),
questions: Vec::new(),
form: Some(form.clone()),
chosen: None,
});
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
let reply = rx.recv().ok().flatten();
let (response, chosen) = elicitation::answer(&form, reply.as_deref());
self.settle(entry, chosen);
Ok(response)
}
/// Records the answer to the request at `entry`; the turn runs on once nothing else waits.
fn settle(self: &Arc<Self>, entry: usize, chosen: String) {
{
let mut s = self.state.lock();
if let Some(AgentEntry::Permission { chosen: answer, .. }) = s.conversation.main.entries.get_mut(entry) {
*answer = Some(chosen);
}
}
self.mark_dirty([(None, entry)]);
let still_asking = !self.permissions.lock().is_empty();
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
self.set_status(AgentStatus::Running, None);
}
}
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
@@ -2663,18 +2596,9 @@ impl Handler for Client {
let task = self.task.upgrade().ok_or((codes::INTERNAL_ERROR, "task closed".to_owned()))?;
match method {
"session/request_permission" => Ok(task.ask_permission(&params)),
"elicitation/create" => task.elicit(&params, None).map_err(|e| (codes::INVALID_PARAMS, e)),
_ => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {method}"))),
}
}
fn on_request_raw(&self, method: &str, params: Value, raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
if method != "elicitation/create" {
return self.on_request_with_data(method, params);
}
let task = self.task.upgrade().ok_or((codes::INTERNAL_ERROR, "task closed".to_owned(), None))?;
task.elicit(&params, Some(raw)).map_err(|e| (codes::INVALID_PARAMS, e, None))
}
}
/// The prompt trimmed, or `None` when there is nothing to send.
@@ -2941,27 +2865,13 @@ fn shown_background(tasks: &[Value], known: &[AgentBackgroundTask]) -> Vec<Agent
.collect()
}
/// The method that adds a message to the running turn: OpenIDEA's own, or the
/// `_session/steering` extension an agent advertises in the response's top-level `_meta`
/// (OMP).
fn steer_method(init: &acp::Negotiated) -> Option<&'static str> {
let supported = |v: Option<&Value>| v.and_then(Value::as_bool) == Some(true);
if supported(init.meta("/openidea/steer")) {
Some("_openidea/steer")
} else if supported(init.meta.as_ref().and_then(|m| m.pointer("/steering/supported"))) {
Some("_session/steering")
} else {
None
}
}
fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true);
let capabilities = AgentCapabilities {
images: init.prompt("image"),
fork: init.session("fork"),
rollback: meta("/openidea/rollback"),
steer: steer_method(init).is_some(),
steer: meta("/openidea/steer"),
background_tasks: meta("/openidea/backgroundTasks"),
read_only: false,
};
-69
Переглянути файл
@@ -130,14 +130,6 @@ pub struct AgentConfigValue {
pub value: String,
pub name: String,
pub description: Option<String>,
/// Relative cost category from ACP `_meta` (`copilotPriceCategory`, e.g. `low`, `medium`, `high`, `very_high`).
#[serde(default)]
#[specta(optional)]
pub cost_category: Option<String>,
/// Usage multiplier from ACP `_meta` (`copilotUsage`, e.g. `1x`, `0x`, `3x`).
#[serde(default)]
#[specta(optional)]
pub usage_multiplier: Option<String>,
}
/// How a setting is changed.
@@ -163,10 +155,6 @@ pub struct AgentConfigOption {
/// ACP category: `model`, `mode`, `thought_level`, or agent-specific.
pub category: Option<String>,
pub current: Option<String>,
/// Initial default value reported by the agent, preserved across session updates for Reset.
#[serde(default)]
#[specta(optional)]
pub default_value: Option<String>,
pub values: Vec<AgentConfigValue>,
pub via: AgentConfigVia,
}
@@ -328,59 +316,6 @@ pub struct AgentQuestionOption {
pub preview: Option<String>,
}
/// A form the agent asks the user to fill in (ACP `elicitation/create`, form mode).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentForm {
pub fields: Vec<AgentFormField>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentFormField {
/// The property name the answer is keyed by.
pub name: String,
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub required: bool,
pub kind: AgentFormFieldKind,
/// The choices of a `choice` or `choices` field.
#[serde(default)]
pub options: Vec<AgentFormOption>,
/// The initial answer: option values, a text, a number, or `true` / `false`.
#[serde(default)]
pub default: Vec<String>,
/// Bounds: the number of a `number` / `integer`, the length of a `text`, the count of `choices`.
#[serde(default)]
pub min: Option<f64>,
#[serde(default)]
pub max: Option<f64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentFormFieldKind {
Text,
Number,
Integer,
Boolean,
/// One of `options`.
Choice,
/// Any of `options`.
Choices,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentFormOption {
pub value: String,
pub label: String,
#[serde(default)]
pub description: String,
}
/// One item of a task's conversation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
@@ -461,10 +396,6 @@ pub enum AgentEntry {
/// of question to answer instead of an option.
#[serde(default)]
questions: Vec<AgentQuestion>,
/// Set when the agent asks for a form (ACP elicitation); answered with a JSON object of
/// field name to its values, `decline`, or nothing to cancel.
#[serde(default)]
form: Option<AgentForm>,
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
chosen: Option<String>,
},
-158
Переглянути файл
@@ -51,9 +51,6 @@ struct FakeAgent {
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
/// Advertises steering as OMP does (`_meta.steering` at the top of `initialize`)
/// instead of `_meta.openidea.steer`.
session_steering: bool,
}
impl FakeAgent {
@@ -159,10 +156,6 @@ impl Handler for FakeAgent {
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
self.calls.lock().push("subagents".into());
}
if self.session_steering {
return Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true},
"_meta": {"steering": {"supported": true}}}));
}
Ok(
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
@@ -190,13 +183,6 @@ impl Handler for FakeAgent {
self.calls.lock().push(format!("{method} {detail}"));
Ok(json!({}))
}
"_session/steering" => {
let text = params["prompt"][0]["text"].clone();
self.calls.lock().push(format!("{method} {text}"));
// OMP answers whether the message joined the turn rather than failing.
let outcome = if text == "too late" { "promptRequired" } else { "injected" };
Ok(json!({"outcome": outcome, "queue": "steering"}))
}
"session/load" => {
self.say(&session, "replayed history");
Ok(json!({}))
@@ -295,23 +281,6 @@ impl Handler for FakeAgent {
self.say(&session, &format!("escalation chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "form" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"elicitation/create",
json!({"sessionId": session, "mode": "form", "message": "How should I refactor?",
"requestedSchema": {"type": "object", "required": ["strategy"], "properties": {
"strategy": {"type": "string", "title": "Strategy",
"oneOf": [{"const": "safe", "title": "Conservative"},
{"const": "bold", "title": "Aggressive"}]},
"tests": {"type": "boolean", "title": "Run tests"}}}}),
None,
))
.unwrap();
self.say(&session, &format!("form {answer}"));
return Ok(json!({ "stopReason": "end_turn" }));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
@@ -1091,42 +1060,6 @@ fn text(t: &str) -> AgentPrompt {
AgentPrompt { text: t.into(), ..AgentPrompt::default() }
}
#[tokio::test(flavor = "multi_thread")]
async fn steers_through_session_steering() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { session_steering: true, ..FakeAgent::default() });
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("wait"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Running).await;
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.steer);
mgr.prompt(&task.id, text("also this"), true).await.unwrap();
assert!(agent.calls.lock().iter().any(|c| c == "_session/steering \"also this\""));
let entries = mgr.entries(&task.id, None).unwrap();
assert!(matches!(entries.last(), Some(AgentEntry::User { steered: true, .. })));
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
// The agent didn't take it into the turn: the turn stops and it goes next, as without
// steering (the fake agent's next turn asks for permission).
mgr.prompt(&task.id, text("too late"), true).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(entries.iter().any(|e| matches!(e, AgentEntry::User { text, steered: false, .. } if text == "too late")));
assert!(!entries.iter().any(|e| matches!(e, AgentEntry::User { text, steered: true, .. } if text == "too late")));
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
let tmp = tempfile::tempdir().unwrap();
@@ -1795,45 +1728,6 @@ async fn thinking_levels_are_remembered_per_provider_and_model() {
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn changing_an_idle_tasks_settings_keeps_its_models_effort_levels() {
// After a model switch no session reported, picking that model again or changing
// another setting must not record the task's effort-less copy as the model's levels.
for (option, value) in [("mode", "code"), ("model", "big")] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.model_options.lock() = HashMap::from([
("big".into(), effort_options(&["low", "high"], "high")),
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
]);
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
let restored = manager(tmp.path(), agent);
restored.set_config(&task.id, "model", "small".into()).await.unwrap();
restored.set_config(&task.id, "model", "big".into()).await.unwrap();
restored.set_config(&task.id, option, value.into()).await.unwrap();
let p = restored.providers().into_iter().find(|p| p.id == "claude").unwrap();
let big = p.thought_levels.get("big").and_then(|levels| levels.first());
assert_eq!(big.map(|o| o.values.len()), Some(2), "{option}: {:?}", p.thought_levels);
let saved: HashMap<String, Known> =
serde_json::from_slice(&std::fs::read(&restored.shared.paths.models_file).unwrap()).unwrap();
assert_eq!(saved["claude"].thought_levels, p.thought_levels, "{option}");
restored.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn upfront_effort_is_validated_after_changing_model_before_the_prompt() {
for (effort, applied) in [("medium", true), ("high", false)] {
@@ -2091,58 +1985,6 @@ async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permis
}
}
#[tokio::test(flavor = "multi_thread")]
async fn an_agent_form_is_answered_declined_or_cancelled_even_when_approvals_are_bypassed() {
let accept = json!({"strategy": ["bold"], "tests": ["true"]}).to_string();
for (reply, said, chosen) in [
(
Some(accept.as_str()),
r#"form {"action":"accept","content":{"strategy":"bold","tests":true}}"#,
"Strategy: Aggressive; Run tests: yes",
),
(Some("decline"), r#"form {"action":"decline"}"#, "Decline"),
(None, r#"form {"action":"cancel"}"#, "Cancelled"),
] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("form"),
provider: "cursor".into(),
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let (id, form) = mgr
.entries(&task.id, None)
.unwrap()
.into_iter()
.find_map(|entry| match entry {
AgentEntry::Permission { id, title, form: Some(form), chosen: None, .. }
if title == "How should I refactor?" =>
{
Some((id, form))
}
_ => None,
})
.unwrap();
assert_eq!(form.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["strategy", "tests"]);
mgr.respond(&task.id, id, reply.map(str::to_owned)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == said)), "{entries:?}");
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == chosen)),
"{entries:?}"
);
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_choice_applies_before_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
-1
Переглянути файл
@@ -441,7 +441,6 @@ mod tests {
command: None,
options: vec![],
questions: vec![],
form: None,
chosen: None,
});
assert_eq!(t.end_turn(None), vec![2, 3]);
+4 -8
Переглянути файл
@@ -218,20 +218,16 @@ where
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// The built-in themes that were removed (#2981; the One Dark Pro plugin
/// maintains their look) and the built-in that stands in for each: read by
/// [`theme_id`], and the removal notice looks for them in settings files.
pub const REMOVED_THEMES: [(&str, &str); 2] = [("one-light", "light"), ("one-dark", "dark")];
/// A theme id, with the built-in themes that were removed read as the Islands theme of their kind.
fn theme_id<'de, D>(d: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let id = String::deserialize(d)?;
Ok(match REMOVED_THEMES.iter().find(|(removed, _)| *removed == id.as_str()) {
Some((_, stand_in)) => (*stand_in).to_owned(),
None => id,
Ok(match id.as_str() {
"one-light" => "light".into(),
"one-dark" => "dark".into(),
_ => id,
})
}
-2
Переглянути файл
@@ -19,7 +19,6 @@ pub enum Tool {
Maven,
Gradle,
Ruby,
Zig,
}
impl Tool {
@@ -33,7 +32,6 @@ impl Tool {
Tool::Maven => &["maven"],
Tool::Gradle => &["gradle"],
Tool::Ruby => &["ruby"],
Tool::Zig => &["zig"],
}
}
}
-21
Переглянути файл
@@ -48,9 +48,6 @@ pub enum Capability {
Ipc(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Send a running language server (its name in the language definition) its own
/// requests. The server reads and edits project files on the plugin's behalf.
Lsp(String),
/// Read files outside the project under one path.
FsRead(String),
/// Write files outside the project under one path.
@@ -88,7 +85,6 @@ impl Capability {
| Self::IdeCommands
| Self::Ipc(_)
| Self::Process(_)
| Self::Lsp(_)
| Self::FsRead(_)
| Self::FsWrite(_) => Risk::High,
}
@@ -130,9 +126,6 @@ impl Capability {
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
}
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::Lsp(server) => {
format!("Send requests to the {server} language server, which reads and changes the project's files")
}
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
}
@@ -256,7 +249,6 @@ impl fmt::Display for Capability {
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::Lsp(s) => write!(f, "lsp:{s}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
}
@@ -306,12 +298,6 @@ impl FromStr for Capability {
}
Ok(Self::Process(arg.to_owned()))
}
"lsp" => {
if arg.len() > 128 || arg.chars().any(|c| c.is_whitespace() || c.is_control() || c == ':') {
return Err(format!("{s:?}: use the server's name in its language definition"));
}
Ok(Self::Lsp(arg.to_owned()))
}
"fs.read" | "fs.write" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
@@ -390,8 +376,6 @@ mod tests {
"net:crates.io",
"net:*.github.com",
"process:cargo",
"lsp:phoxite",
"lsp:rust-analyzer",
] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
@@ -408,9 +392,6 @@ mod tests {
assert!("net:".parse::<Capability>().is_err());
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("lsp:".parse::<Capability>().is_err());
assert!("lsp:a b".parse::<Capability>().is_err());
assert!("lsp:phoxite:phoxite/laravel".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("fs.write:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
@@ -424,8 +405,6 @@ mod tests {
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
assert_eq!(Capability::Lsp("phoxite".into()).risk(), Risk::High);
assert!(!Capability::Lsp("phoxite".into()).covers(&Capability::Lsp("intelephense".into())));
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
-18
Переглянути файл
@@ -357,9 +357,6 @@ impl Manifest {
if matches!(cap, Capability::NetAsk) {
self.needs_api(&format!("capability `{cap}`"), 5)?;
}
if matches!(cap, Capability::Lsp(_)) {
self.needs_api(&format!("capability `{cap}`"), 9)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
@@ -715,21 +712,6 @@ mod tests {
}
}
#[test]
fn language_server_capabilities_need_api_1_9() {
let caps = "[capabilities]
required = [\"lsp:phoxite\"]
";
for engine in ["^1.2", "^1.8"] {
assert!(
err(&format!("{}{caps}", BASE.replace("^1.2", engine))).contains("new in plugin API 1.9"),
"{engine}"
);
}
let m = Manifest::parse(&format!("{}{caps}", BASE.replace("^1.2", "^1.9"))).unwrap();
assert_eq!(m.capabilities.required, [Capability::Lsp("phoxite".into())]);
}
#[test]
fn parses_icons() {
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
-9
Переглянути файл
@@ -65,15 +65,6 @@ pub fn settings(host: &Host, window: &str) -> Settings {
})
}
/// The removed built-in theme a settings file of this window still names, if any
/// (`settings` reports its stand-in): the "provided by a plugin now" notice targets these.
pub fn settings_removed_theme(host: &Host, window: &str) -> Option<String> {
match host.project(window) {
Ok(p) => p.settings.named_removed_theme().or_else(|| host.global_settings.named_removed_theme()),
Err(_) => host.global_settings.named_removed_theme(),
}
}
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
Ok(host.project(window)?.roots())
}
-72
Переглянути файл
@@ -97,21 +97,6 @@ pub trait PluginWorkspace: Send + Sync {
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
None
}
/// Sends `method` to the running language server named `server` of the project at
/// `root`: a request, or with `notify` a notification (the result is null).
#[allow(clippy::too_many_arguments)]
fn lsp_request(
&self,
_root: &Path,
server: &str,
_method: &str,
_params: Option<serde_json::Value>,
_notify: bool,
_cancel: &CancellationToken,
_timeout: Duration,
) -> Result<serde_json::Value, HostError> {
Err(HostError::NotFound(format!("no language server `{server}` is running for this project")))
}
/// Current index of the project rooted at `root`; never reads disk or calls LSP.
fn symbols(
&self,
@@ -2195,35 +2180,6 @@ impl HostApi for PluginApi {
)
}
fn lsp_request(
&self,
root: &Path,
server: &str,
method: &str,
params: Option<String>,
notify: bool,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<String, HostError> {
self.require_api("`language-servers`", 9)?;
if !ide_lsp::plugin_method_allowed(method) {
return Err(HostError::Invalid(format!("`{method}` is not one of the server's own methods")));
}
let workspace = self
.shared
.workspace()
.ok_or_else(|| HostError::NotFound(format!("no language server `{server}` is running for this project")))?;
// The server reads and edits the project on the plugin's behalf, as a process would.
if !workspace.trusted(root) {
return Err(HostError::Denied("project is not trusted".into()));
}
let params = params
.map(|p| serde_json::from_str(&p).map_err(|e| HostError::Invalid(format!("params are not JSON: {e}"))))
.transpose()?;
let result = workspace.lsp_request(root, server, method, params, notify, cancel, timeout)?;
Ok(result.to_string())
}
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
let matcher = globset::GlobBuilder::new(glob)
.literal_separator(true)
@@ -3346,34 +3302,6 @@ impl PluginWorkspace for HostWorkspace {
}
}
fn lsp_request(
&self,
root: &Path,
server: &str,
method: &str,
params: Option<serde_json::Value>,
notify: bool,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<serde_json::Value, HostError> {
use ide_core::error::ErrorKind;
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
let project = HostWorkspace::project_covering(self, root)
.ok_or_else(|| HostError::NotFound("the project is no longer open".into()))?;
let result = if notify {
project.lsp.server_notify(root, server, method, params).map(|()| serde_json::Value::Null)
} else {
host.runtime.block_on(project.lsp.server_request(root, server, method, params, cancel, timeout))
};
result.map_err(|e| match e.kind {
// A server that stopped while the request was on its way is as absent as one never started.
ErrorKind::NotFound | ErrorKind::Unavailable => HostError::NotFound(e.message),
ErrorKind::InvalidInput => HostError::Invalid(e.message),
ErrorKind::Cancelled => HostError::Cancelled,
_ => HostError::Failed(e.message),
})
}
fn trusted(&self, root: &Path) -> bool {
// A workspace member is as trusted as the group's project it was opened with.
HostWorkspace::project_covering(self, root).is_some_and(|p| p.is_trusted())
+1 -3
Переглянути файл
@@ -1275,9 +1275,7 @@ impl Project {
}
let defaults = self.documents.file_defaults();
let fallback = defaults.fallback_for(path);
// The working file only when it is a regular file: a path that is a directory there
// (a committed symlink to a folder, a file replaced by a folder) has only its blob.
if path.is_file() {
if path.exists() {
let loaded = ide_vfs::load_text_with_fallback(path, fallback)?;
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
-1
Переглянути файл
@@ -165,7 +165,6 @@ impl Dispatcher {
})
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
.add("settings_removed_theme", |c| async move { Ok(ops::project::settings_removed_theme(&c.host, &c.window)) })
.add("settings_set", |c| async move {
let key: String = c.arg("key")?;
let scope = c.arg("scope")?;
-1
Переглянути файл
@@ -408,7 +408,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "settings_export"
| "settings_get"
| "settings_get_global"
| "settings_removed_theme"
| "settings_import"
| "settings_list_jetbrains_ides"
| "settings_read_jetbrains"
-85
Переглянути файл
@@ -31,8 +31,6 @@ struct TestWorkspace {
/// plugin → its settings as the user set them.
settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
global_settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
/// What plugins sent the language server `mock`: (method, params, whether a notification).
server_messages: Mutex<Vec<(String, Option<serde_json::Value>, bool)>>,
}
impl TestWorkspace {
@@ -51,7 +49,6 @@ impl TestWorkspace {
diagnostics: Mutex::default(),
settings: Mutex::default(),
global_settings: Mutex::default(),
server_messages: Mutex::default(),
})
}
@@ -179,23 +176,6 @@ impl PluginWorkspace for TestWorkspace {
fn trusted(&self, _root: &Path) -> bool {
self.trusted
}
/// One language server runs for the project, `mock`: it answers with what it was sent.
fn lsp_request(
&self,
_root: &Path,
server: &str,
method: &str,
params: Option<serde_json::Value>,
notify: bool,
_cancel: &ide_core::CancellationToken,
_timeout: Duration,
) -> Result<serde_json::Value, HostError> {
if server != "mock" {
return Err(HostError::NotFound(format!("no language server `{server}` is running for this project")));
}
self.server_messages.lock().push((method.into(), params.clone(), notify));
Ok(if notify { serde_json::Value::Null } else { serde_json::json!({ "method": method, "params": params }) })
}
fn project_windows(&self, root: &Path) -> Vec<String> {
// `main` shows the fixture project; other open roots have no window left.
if root == self.root { vec!["closing".into(), "main".into()] } else { Vec::new() }
@@ -2626,7 +2606,6 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
diagnostics: Mutex::default(),
settings: Mutex::default(),
global_settings: Mutex::default(),
server_messages: Mutex::default(),
});
f.host.set_workspace(Some(untrusted));
let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err();
@@ -6951,67 +6930,3 @@ fn project_write_judges_a_link_by_its_target() {
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "top secret", "{rel}");
}
}
/// A plugin reaches a running language server only with `lsp:<server>`, in a trusted
/// project, and only with the server's own methods (API 1.9).
#[test]
fn language_server_requests_need_the_servers_grant_and_its_own_methods() {
use serde_json::json;
let f = fixture(Limits::default());
let pkg = repo().join("crates/ide-plugin/tests/fixtures/lsp-requests");
let id = "test.lsp-requests";
let info = f.install(&pkg);
assert!(info.capabilities.iter().any(|c| c.id == "lsp:mock" && c.granted && c.risk == ide_extension::Risk::High));
// The request and its JSON params reach the server; its answer comes back as JSON.
let answer = f.run(id, "request", Some(r#"{"verb":"GET"}"#)).unwrap().unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&answer).unwrap(),
json!({ "method": "mock/routes", "params": { "verb": "GET" } })
);
assert_eq!(f.run(id, "notify", None).unwrap(), None);
assert_eq!(
*f.workspace.server_messages.lock(),
[("mock/routes".to_owned(), Some(json!({ "verb": "GET" })), false), ("mock/changed".to_owned(), None, true)]
);
// Params that are not JSON and LSP's own methods stop at the host.
assert!(f.run(id, "request", Some("{")).unwrap_err().contains("params are not JSON"));
assert!(f.run(id, "standard", None).unwrap_err().contains("not one of the server's own methods"));
assert_eq!(f.workspace.server_messages.lock().len(), 2);
// The grant names one server: another one is denied, not merely absent.
let err = f.host.run_command(id, "ungranted", None, f.inv()).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
assert!(err.message.contains("lsp:other"), "{err:?}");
// Without the grant the call is denied and nothing is sent.
f.host.set_capability(id, "lsp:mock", false).unwrap();
let err = f.host.run_command(id, "request", None, f.inv()).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
assert!(err.message.contains("lsp:mock"), "{err:?}");
f.host.set_capability(id, "lsp:mock", true).unwrap();
f.run(id, "request", None).unwrap();
// A server acts on the project's files: not in an untrusted project.
let mut untrusted = TestWorkspace::new(&f.project);
Arc::get_mut(&mut untrusted).unwrap().trusted = false;
f.host.set_workspace(Some(untrusted.clone()));
let err = f.host.run_command(id, "request", None, f.inv()).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
assert!(untrusted.server_messages.lock().is_empty());
assert_eq!(f.host.info_of(id).unwrap().stats.crashes, 0);
}
/// The grant is new in API 1.9: an older `engines.openidea` can't ask for it.
#[test]
fn the_language_server_grant_needs_api_1_9() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("lsp-requests");
copy_dir(&repo().join("crates/ide-plugin/tests/fixtures/lsp-requests"), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.9\"", "\"^1.8\"")).unwrap();
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.9"), "{err:?}");
}
-46
Переглянути файл
@@ -111,49 +111,3 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
assert_eq!(vcs::vcs_file_at(&host, "main", utf8, None).await.unwrap().as_deref(), Some(old));
host.close_project("main");
}
/// A committed symlink to a folder (#3528): its revision is the link target, even though the
/// working path is a directory.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_directory_symlink_revision_is_its_target() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(root.join("target")).unwrap();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "[email protected]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
std::fs::write(root.join("target/file.txt"), "example\n").unwrap();
std::os::unix::fs::symlink("target", root.join("dir-link")).unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "Add a directory symlink"]);
let host = 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(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let link = root.join("dir-link");
assert!(link.is_dir());
assert_eq!(
vcs::vcs_file_at(&host, "main", link.clone(), Some("HEAD".into())).await.unwrap().as_deref(),
Some("target")
);
assert_eq!(vcs::vcs_file_mode(&host, "main", link, Some("HEAD".into())).await.unwrap().as_deref(), Some("120000"));
host.close_project("main");
}
+11 -25
Переглянути файл
@@ -78,8 +78,8 @@ impl Needle {
let mut b = 0;
if n.eq_ignore_ascii_case(q) {
b += EXACT;
} else if let Some(dot) = n.iter().position(|&b| b == b'.')
&& n[..dot].eq_ignore_ascii_case(q)
} else if let Some(dot) = name.find('.')
&& name.as_bytes()[..dot].eq_ignore_ascii_case(q)
{
b += EXACT_STEM;
} else if n.len() >= q.len() && n[..q.len()].eq_ignore_ascii_case(q) {
@@ -97,31 +97,17 @@ fn hump_bonus(s: &str, indices: &[u32]) -> u32 {
if indices.is_empty() {
return 0;
}
// One pass over `s`: `indices` is sorted, and whether position `i` is a
// word start only needs the char before it, so no `Vec<char>` is collected.
let is_start = |prev: Option<char>, c: char| match prev {
None => true,
Some(p) => {
!p.is_alphanumeric()
|| (p.is_lowercase() && c.is_uppercase())
|| (!p.is_ascii_digit() && c.is_ascii_digit())
let chars: Vec<char> = s.chars().collect();
let is_start = |i: usize| {
if i == 0 {
return true;
}
let (p, c) = (chars[i - 1], chars[i]);
!p.is_alphanumeric() || (p.is_lowercase() && c.is_uppercase()) || (!p.is_ascii_digit() && c.is_ascii_digit())
};
let (mut humps, mut next, mut first_start) = (0u32, 0usize, false);
let (mut prev, mut at) = (None, 0u32);
for c in s.chars() {
if next < indices.len() && indices[next] == at {
let started = is_start(prev, c);
humps += u32::from(started);
if next == 0 {
first_start = started;
}
next += 1;
}
prev = Some(c);
at += 1;
}
humps * HUMP + u32::from(first_start) * FIRST_AT_START
let humps = indices.iter().filter(|&&i| (i as usize) < chars.len() && is_start(i as usize)).count() as u32;
let first = if is_start(indices[0] as usize) { FIRST_AT_START } else { 0 };
humps * HUMP + first
}
#[derive(Clone, Copy)]
+8 -21
Переглянути файл
@@ -215,18 +215,12 @@ pub trait Handler: Send + Sync + 'static {
/// Respond to a server→client request (e.g. `workspace/configuration`).
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)>;
/// [`on_request`](Self::on_request) for handlers whose errors carry `data`.
/// [`on_request`](Self::on_request) for handlers whose errors carry `data`. This is the
/// one the connection calls.
fn on_request_with_data(&self, method: &str, params: Value) -> Result<Value, (i64, String, Option<Value>)> {
self.on_request(method, params).map_err(|(code, message)| (code, message, None))
}
/// [`on_request_with_data`](Self::on_request_with_data) with the request as received, for
/// handlers that need what `Value` drops, such as the order of an object's keys. This is the
/// one the connection calls.
fn on_request_raw(&self, method: &str, params: Value, _raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
self.on_request_with_data(method, params)
}
/// Called once the response to a `method` request is queued: what it sends follows the response.
fn after_response(&self, _method: &str) {}
@@ -583,8 +577,8 @@ impl Peer {
tracing::error!("jsonrpc notification handler panicked on {method}");
}
}
Incoming::Request(id, method, params, raw) => {
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params, raw));
Incoming::Request(id, method, params) => {
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params));
}
Incoming::Response(tx, result) => {
let _ = tx.send(result);
@@ -767,21 +761,14 @@ fn parse_id(id: &Value) -> Option<i64> {
enum Incoming {
Notification(String, Value),
Request(Value, String, Value, Arc<[u8]>),
Request(Value, String, Value),
Response(oneshot::Sender<Result<Value, RpcError>>, Result<Value, RpcError>),
}
async fn handle_request(
inner: Arc<Inner>,
handler: Arc<dyn Handler>,
id: Value,
method: String,
params: Value,
raw: Arc<[u8]>,
) {
async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value, method: String, params: Value) {
let m = method.clone();
let h = handler.clone();
let result = tokio::task::spawn_blocking(move || h.on_request_raw(&m, params, &raw)).await;
let result = tokio::task::spawn_blocking(move || h.on_request_with_data(&m, params)).await;
let reply = match result {
Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Ok(Err((code, message, data))) => {
@@ -821,7 +808,7 @@ fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mps
let params = obj.remove("params").unwrap_or(Value::Null);
match id {
Some(id) if !id.is_null() => {
let _ = notif_tx.send(Incoming::Request(id, method, params, Arc::from(body)));
let _ = notif_tx.send(Incoming::Request(id, method, params));
}
_ => {
let _ = notif_tx.send(Incoming::Notification(method, params));
-5
Переглянути файл
@@ -170,11 +170,6 @@ pub struct LanguageServerConfig {
/// basedpyright): only used while none of those is installed.
#[serde(default)]
pub fallback: bool,
/// Names of servers listed after this one that it makes redundant (a PHP server
/// with its own Laravel support replaces `laravel-lsp`): they aren't used while
/// this one serves the file.
#[serde(default)]
pub replaces: Vec<String>,
/// What the server is used for (`completion`, `code-action`, `diagnostics`,
/// … `FEATURES` in `ide-lsp/src/caps.rs`); empty means everything. The other
/// capabilities it announces are ignored, so a refactoring-only server next
+1 -4
Переглянути файл
@@ -758,10 +758,7 @@ mod tests {
let cfg = reg.get(&LanguageId::new("php")).unwrap();
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
// After the fallback: hover, rename and symbols stay with Intelephense.
assert_eq!(names, ["intelephense", "phpactor", "phoxite", "laravel-lsp"]);
// Phoxite answers the Laravel strings itself.
let phoxite = cfg.language_servers.iter().find(|s| s.name == "phoxite").unwrap();
assert!(phoxite.fallback && phoxite.replaces == ["laravel-lsp"]);
assert_eq!(names, ["intelephense", "phpactor", "laravel-lsp"]);
let lsp = cfg.language_servers.iter().find(|s| s.name == "laravel-lsp").unwrap();
assert_eq!(lsp.command.as_str(), "laravel-lsp");
// Its `lsp` command takes no options and speaks over stdio; `--stdio` stops it at startup.
+9 -28
Переглянути файл
@@ -26,7 +26,6 @@ mod launch;
mod library_source;
mod loading;
mod notebook;
mod plugin_requests;
mod pull;
mod scope;
mod server;
@@ -46,7 +45,6 @@ pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use notebook::{NotebookCell, NotebookCellDoc, cell_uri};
pub use plugin_requests::plugin_method_allowed;
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
@@ -662,17 +660,15 @@ impl LspManager {
/// The servers serving `path`: every regular one, and each `fallback` one
/// 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. One that a server before
/// it `replaces` is left out while that server serves the file.
/// 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;
}
let mut out = Vec::new();
let mut available_before = false;
let mut replaced: Vec<String> = Vec::new();
for sc in &self.language_servers(language) {
if (sc.fallback && available_before) || replaced.contains(&sc.name) {
if sc.fallback && available_before {
continue;
}
if sc.require_root_marker
@@ -682,9 +678,6 @@ impl LspManager {
}
let server = self.server_for(sc, language, path);
available_before |= !sc.require_root_marker && !server.gave_up();
if !server.gave_up() {
replaced.extend(sc.replaces.iter().cloned());
}
out.push(server);
}
out
@@ -872,7 +865,7 @@ impl LspManager {
ide_core::event::ServerStatus::Missing,
Some(message),
) {
self.fallbacks_take_over().await;
self.attach_fallbacks();
}
return;
}
@@ -885,7 +878,7 @@ impl LspManager {
ide_core::event::ServerStatus::Misconfigured,
Some(message),
) {
self.fallbacks_take_over().await;
self.attach_fallbacks();
}
return;
}
@@ -898,7 +891,7 @@ impl LspManager {
ide_core::event::ServerStatus::Crashed,
Some(message),
) {
self.fallbacks_take_over().await;
self.attach_fallbacks();
}
return;
}
@@ -1046,7 +1039,7 @@ impl LspManager {
}
}
// Gave up (crashed repeatedly, or its build expired): the fallbacks it hid take over.
None => self.fallbacks_take_over().await,
None => self.attach_fallbacks(),
}
}
@@ -3471,10 +3464,7 @@ impl LspManager {
virtual_doc::is_decompiled(&text)
};
let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled));
let mut detected = self.languages.detect(Path::new(&title), None);
if let Some(stem) = title.strip_suffix(".class").filter(|_| virtual_doc::is_java_class_stub(&text)) {
detected = self.languages.detect(Path::new(&format!("{stem}.java")), None);
}
let detected = self.languages.detect(Path::new(&title), None);
let language_id = if detected == LanguageId::plain_text() {
languages.first().cloned().unwrap_or(detected)
} else {
@@ -3838,16 +3828,8 @@ impl LspManager {
}
}
/// A server gave up: its fallbacks take over the open documents, and the servers
/// they replace leave.
async fn fallbacks_take_over(&self) {
self.attach_fallbacks();
self.detach_shadowed_fallbacks().await;
}
/// A server enabled again shadows the fallbacks attached in its place, and a
/// fallback that took over may replace a server attached earlier: they leave the
/// open documents, and stop once no document uses them.
/// A server enabled again shadows the fallbacks attached in its place: they
/// leave the open documents, and stop once no document uses them.
async fn detach_shadowed_fallbacks(&self) {
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
@@ -3863,7 +3845,6 @@ impl LspManager {
.filter(|a| {
!wanted.iter().any(|w| w.id == a.server.id)
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|| configs.iter().any(|c| c.replaces.contains(&a.server.name))
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER)))
})
.map(|a| a.server.id)
-119
Переглянути файл
@@ -1,119 +0,0 @@
//! Requests a plugin sends to a running server (`language-servers` in the plugin API):
//! the server's own methods only, and only to a server that already runs.
use crate::{LspManager, Target, rpc_err};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde_json::Value;
use std::path::Path;
use std::time::Duration;
/// The first segment of the methods LSP itself defines.
const LSP_NAMESPACES: [&str; 14] = [
"textDocument",
"workspace",
"window",
"notebookDocument",
"callHierarchy",
"typeHierarchy",
"completionItem",
"codeAction",
"codeLens",
"documentLink",
"inlayHint",
"workspaceSymbol",
"client",
"telemetry",
];
const LIFECYCLE: [&str; 4] = ["initialize", "initialized", "shutdown", "exit"];
/// Whether a plugin may send `method`: only a server's own namespace passes
/// (`phoxite/laravel/routes`), so a plugin can't interfere with document sync, the
/// features the IDE asks for itself, or the server's lifecycle.
pub fn plugin_method_allowed(method: &str) -> bool {
let namespace = method.split('/').next().unwrap_or_default();
!(method.is_empty()
|| method.starts_with("$/")
|| LIFECYCLE.contains(&method)
|| LSP_NAMESPACES.contains(&namespace))
}
impl LspManager {
/// The running server named `server`, the one rooted at `root` first. Never starts one.
fn plugin_target(&self, root: &Path, server: &str, method: &str) -> IdeResult<Target> {
if !plugin_method_allowed(method) {
return Err(IdeError::invalid(format!("`{method}` is not one of the server's own methods")));
}
let mut running: Vec<Target> = self
.servers
.lock()
.iter()
.filter(|s| s.name == server)
.filter_map(|s| s.running().map(|run| Target { server: s.clone(), run }))
.collect();
let at = running.iter().position(|t| t.server.root == root).unwrap_or(0);
if running.is_empty() {
return Err(IdeError::not_found(format!("no language server `{server}` is running for this project")));
}
Ok(running.swap_remove(at))
}
/// Sends a plugin's request to the running server named `server` (its name in the
/// language definition) and returns the result. `not_found` when it is not running,
/// `invalid` for a method that is not the server's own.
pub async fn server_request(
&self,
root: &Path,
server: &str,
method: &str,
params: Option<Value>,
cancel: &CancellationToken,
timeout: Duration,
) -> IdeResult<Value> {
let t = self.plugin_target(root, server, method)?;
self.call(&t, method, params.unwrap_or(Value::Null), Some(cancel), timeout).await
}
/// The same for a notification.
pub fn server_notify(&self, root: &Path, server: &str, method: &str, params: Option<Value>) -> IdeResult<()> {
let t = self.plugin_target(root, server, method)?;
t.run.peer.notify(method, params.unwrap_or(Value::Null)).map_err(|e| rpc_err(server, method, e))
}
}
#[cfg(test)]
mod tests {
use super::plugin_method_allowed;
#[test]
fn only_a_servers_own_methods_pass() {
for method in ["phoxite/laravel/routes", "phoxite/refactor/hierarchy", "rust-analyzer/expandMacro", "custom"] {
assert!(plugin_method_allowed(method), "{method}");
}
for method in [
"",
"initialize",
"initialized",
"shutdown",
"exit",
"$/cancelRequest",
"$/progress",
"textDocument/hover",
"textDocument/didChange",
"workspace/executeCommand",
"window/showMessage",
"notebookDocument/didOpen",
"callHierarchy/incomingCalls",
"typeHierarchy/supertypes",
"completionItem/resolve",
"codeAction/resolve",
"codeLens/resolve",
"documentLink/resolve",
"inlayHint/resolve",
"workspaceSymbol/resolve",
"client/registerCapability",
"telemetry/event",
] {
assert!(!plugin_method_allowed(method), "{method}");
}
}
}
-22
Переглянути файл
@@ -110,17 +110,6 @@ pub fn is_decompiled(text: &str) -> bool {
})
}
/// Whether a `.class` stub is written in Java: its first `package` or `import` line ends with `;`.
/// The Kotlin language server shows Java classes from jars as Java and Kotlin ones as Kotlin, under
/// the same `.class` name.
pub fn is_java_class_stub(text: &str) -> bool {
text.lines()
.take(200)
.map(str::trim)
.find(|line| line.starts_with("package ") || line.starts_with("import "))
.is_some_and(|line| line.ends_with(';'))
}
/// `scheme` of a URI (`jdt` for `jdt://…`).
pub fn scheme(uri: &str) -> &str {
uri.split_once(':').map(|(s, _)| s).unwrap_or("")
@@ -216,17 +205,6 @@ mod tests {
const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class";
#[test]
fn class_stubs_in_java_or_kotlin() {
let java = "// IntelliJ API Decompiler stub source generated from a class file\n\npackage com.boydti.fawe.util;\n\npublic class EditSessionBuilder {\n}\n";
let kotlin = "// IntelliJ API Decompiler stub source generated from a class file\n// Implementation of methods is not available\n\npackage kotlin.collections\n\npublic fun <T> listOf(): List<T> { /* compiled code */ }\n";
let imports_only = "import java.util.List;\n\npublic class A {}\n";
assert!(is_java_class_stub(java));
assert!(is_java_class_stub(imports_only));
assert!(!is_java_class_stub(kotlin));
assert!(!is_java_class_stub("public class A {}"));
}
#[test]
fn csharp_metadata_titles_and_kinds() {
for (uri, expected) in [
-118
Переглянути файл
@@ -264,7 +264,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
install_hint: Some(format!("install {name}")),
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
@@ -3287,67 +3286,6 @@ async fn a_server_requiring_a_root_marker_serves_only_files_below_one() {
h.wait_status("alt", ServerStatus::Ready).await;
}
/// A server that `replaces` another (a PHP server with its own Laravel support and
/// `laravel-lsp`) keeps it away while it serves, and only then.
#[tokio::test(flavor = "multi_thread")]
async fn a_replaced_server_stays_away_while_its_replacement_serves() {
let servers = [("main", incremental_caps()), ("alt", incremental_caps()), ("framework", incremental_caps())];
let configure = |cfg: &mut LanguageServerConfig| match cfg.name.as_str() {
"alt" => {
cfg.fallback = true;
cfg.replaces = vec!["framework".into()];
}
"framework" => cfg.require_root_marker = true,
_ => {}
};
let h = harness_configured("mock", "mk", &servers, configure);
let disabled: Arc<Mutex<Vec<String>>> = Default::default();
{
let disabled = disabled.clone();
h.mgr.set_servers_hook(Box::new(move |_, defined| {
defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect()
}));
}
std::fs::write(h.root.join("mock.toml"), "").unwrap();
let (_, path) = h.open("a.mk", "x");
let uri = uri_of(&path);
// The preferred server serves: the framework's server joins it, the alternative stays away.
h.wait_status("main", ServerStatus::Ready).await;
h.wait_status("framework", ServerStatus::Ready).await;
eventually(|| h.launcher.state("framework").docs.lock().contains_key(&uri)).await;
assert!(h.statuses("alt").is_empty());
// The alternative takes over: the server it replaces closes the document and stops.
disabled.lock().push("main".into());
h.mgr.reconfigure().await;
h.wait_status("alt", ServerStatus::Ready).await;
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
assert!(!h.launcher.state("framework").notified("textDocument/didClose").is_empty());
assert_eq!(h.statuses("framework").last(), Some(&ServerStatus::Stopped));
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["alt"]);
// The preferred server again: the framework's server comes back with it.
disabled.lock().clear();
h.mgr.reconfigure().await;
h.wait_status("main", ServerStatus::Ready).await;
eventually(|| h.statuses("framework").last() == Some(&ServerStatus::Ready)).await;
assert_eq!(h.statuses("alt").last(), Some(&ServerStatus::Stopped));
// A preferred server that is not installed: the alternative replaces the framework's
// server although that one attached first.
let servers = [("ghost", json!({})), ("alt", incremental_caps()), ("framework", incremental_caps())];
let h = harness_configured("mock", "mk", &servers, configure);
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
std::fs::write(h.root.join("mock.toml"), "").unwrap();
let (_, path) = h.open("a.mk", "x");
let uri = uri_of(&path);
h.wait_status("ghost", ServerStatus::Missing).await;
h.wait_status("alt", ServerStatus::Ready).await;
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
eventually(|| !h.mgr.servers().iter().any(|s| s.name == "framework")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_fallback_leaves_when_the_preferred_server_is_enabled_again() {
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
@@ -7372,59 +7310,3 @@ async fn notebook_edits_change_cell_text_and_reorders_reopen_it() {
.await;
h.mgr.shutdown_all().await;
}
/// A plugin's request (`language-servers` in the plugin API) reaches the running server
/// named in the language definition: only the server's own methods, and never a start.
#[tokio::test(flavor = "multi_thread")]
async fn a_plugin_request_reaches_only_a_running_servers_own_methods() {
let servers = [("main", incremental_caps()), ("alt", incremental_caps())];
let h = harness_for("mock", "mk", &servers, &["alt"]);
let _ = h.open("a.mk", "x");
h.wait_status("main", ServerStatus::Ready).await;
let s = h.launcher.state("main");
s.respond("mock/routes", json!([{ "uri": "/users", "name": "users.index" }]));
let cancel = CancellationToken::new();
let timeout = Duration::from_secs(5);
// A request: the params arrive, the result comes back.
let routes = h
.mgr
.server_request(&h.root, "main", "mock/routes", Some(json!({ "method": "GET" })), &cancel, timeout)
.await
.unwrap();
assert_eq!(routes, json!([{ "uri": "/users", "name": "users.index" }]));
assert_eq!(s.requested("mock/routes"), [json!({ "method": "GET" })]);
// A notification.
h.mgr.server_notify(&h.root, "main", "mock/changed", Some(json!({ "what": "routes" }))).unwrap();
eventually(|| s.notified("mock/changed") == [json!({ "what": "routes" })]).await;
// LSP's own methods are refused before anything is sent.
for method in ["textDocument/hover", "workspace/executeCommand", "$/cancelRequest", "shutdown", "initialize"] {
let before = (s.requested(method).len(), s.notified(method).len());
let e = h.mgr.server_request(&h.root, "main", method, None, &cancel, timeout).await.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput, "{method}");
assert_eq!(
h.mgr.server_notify(&h.root, "main", method, None).unwrap_err().kind,
ide_core::error::ErrorKind::InvalidInput
);
assert_eq!((s.requested(method).len(), s.notified(method).len()), before, "{method}");
}
// A server that is defined but not running is not started; an unknown name is the same.
for server in ["alt", "nobody"] {
let e = h.mgr.server_request(&h.root, server, "mock/routes", None, &cancel, timeout).await.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::NotFound, "{server}");
assert_eq!(
h.mgr.server_notify(&h.root, server, "mock/changed", None).unwrap_err().kind,
ide_core::error::ErrorKind::NotFound
);
}
assert_eq!(h.launcher.state("alt").launches.load(Ordering::SeqCst), 0);
assert!(h.statuses("alt").is_empty());
// A cancelled invocation ends the request.
cancel.cancel();
let e = h.mgr.server_request(&h.root, "main", "mock/routes", None, &cancel, timeout).await.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::Cancelled);
}
-3
Переглянути файл
@@ -419,7 +419,6 @@ async fn missing_binary_is_reported_with_install_hint() {
install_hint: Some("npm i -g nope-ls".into()),
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
@@ -693,7 +692,6 @@ async fn typescript_language_server_end_to_end() {
install_hint: None,
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
@@ -747,7 +745,6 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
install_hint: None,
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
-17
Переглянути файл
@@ -100,23 +100,6 @@ pub trait HostApi: Send + Sync {
let _ = (root, client, id, cancel, timeout);
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
}
/// Sends `method` to the running language server named `server` of the project at
/// `root`: a request (the result as JSON), or with `notify` a notification (`null`).
/// `params` is JSON. Never starts a server.
#[allow(clippy::too_many_arguments)]
fn lsp_request(
&self,
root: &Path,
server: &str,
method: &str,
params: Option<String>,
notify: bool,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<String, HostError> {
let _ = (root, method, params, notify, cancel, timeout);
Err(HostError::NotFound(format!("no language server `{server}` is running for this project")))
}
/// Stdio argv only; no URL or bearer token.
fn mcp_stdio_command(&self, client: &str) -> Option<Vec<String>> {
let _ = client;
-54
Переглянути файл
@@ -59,7 +59,6 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
@@ -98,7 +97,6 @@ pub(crate) mod providers_bindings {
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
@@ -137,7 +135,6 @@ pub(crate) mod views_bindings {
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
@@ -2651,57 +2648,6 @@ impl bindings::openidea::plugin::database::Host for StoreData {
Ok(result.map_err(to_wit))
}
}
/// The longest method name and params a plugin may send a language server.
const MAX_LSP_METHOD: usize = 256;
const MAX_LSP_PARAMS: usize = 1 << 20;
impl StoreData {
fn lsp_call(
&mut self,
project: Resource<ProjectRes>,
server: String,
method: String,
params: Option<String>,
notify: bool,
) -> Fallible<String> {
if let Err(e) = self.require(Capability::Lsp(server.clone())) {
return Ok(Err(e));
}
if method.len() > MAX_LSP_METHOD || params.as_ref().is_some_and(|p| p.len() > MAX_LSP_PARAMS) {
return Ok(Err(wit::Error::Invalid("method exceeds 256 bytes or params exceed 1 MiB".into())));
}
let root = self.table.get(&project)?.root.clone();
let token = self.cancel.token();
let timeout = self.io_timeout();
let outcome = self.host.lsp_request(&root, &server, &method, params, notify, &token, timeout);
if outcome.is_err() {
self.grant_io_error_grace();
}
Ok(outcome.map_err(to_wit))
}
}
impl bindings::openidea::plugin::language_servers::Host for StoreData {
fn request(
&mut self,
project: Resource<ProjectRes>,
server: String,
method: String,
params: Option<String>,
) -> Fallible<String> {
self.lsp_call(project, server, method, params, false)
}
fn notify(
&mut self,
project: Resource<ProjectRes>,
server: String,
method: String,
params: Option<String>,
) -> Fallible<()> {
Ok(self.lsp_call(project, server, method, params, true)?.map(|_| ()))
}
}
impl bindings::openidea::plugin::extensions::Host for StoreData {
fn contributions(&mut self, point: String) -> Fallible<Vec<bindings::openidea::plugin::extensions::Contribution>> {
Ok(self
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
75123cf60f87ec9bdd36e0b7f75efb69d000136d74c3e9da893ecd1534bdf534
a3d2511f30277245a8cb9673c4d8a948a8a025b8b9149d5015bed5137a98ccdd
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
60d66ec578e3adc7adaebbcd01aa92a74a8a7e4cfd4d1aa778f9b033c75234a6
dad3edad99ad85234aa612bbc1f3a66e50361189e54038b91674703815f049cc
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
df55973ed77e734e48fe1d2b7bb5d1e4eb68153b17452e4385599145427292b6
918af7d4d8bc302621b9b68070a56930813b1fc2ba235d89de4e56c53eaba5bd
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
94f1c4a8192e15961bd136cf9dc3416fca7b295bce0d563608a3ee9fdf81c28e
7a140c2255a3d251a5ab9079bec953cccc4408727b067b49da47cf8fb27d8cb8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
db140480eaf4352fa8db8d87aab95cb07944e19c28d2cc08e299b37c5deb4230
ef41934bd0b67f76a17ef321b44c172496314c9fea6b46b4cd2f1d0f4b96907d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5fd76f6168d3a7b3259281171f375cf01da6ceabdf4ffd382c5fe7034b2e94f2
c6dc1ae00d9f3d936f4bf5ff110915050cb503ac06982bdc48e967deac84a913
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5d9049fa006194e92e06de47ab933d249118706a8ba92100493318a1582f2b65
25a782556dd6bbf8ed1ad7b50b9b55685f074d80220af08f3bd9cb12cf07f74e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c723d915b69292740a863b3a94db6f6f8c218ecda46c527229d621084f2df3b1
f58b5f68d56611c3d15f69ff8e73e6e65d84e55d14c9bae00ed50b346460adfa
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2dbab98cfc473650675b2691c7d0f2d5b088ffd6208e31ef30aaa54dd3b1b4b0
749bff2d051984edf65590494ef803225dfccd03d032de140974d8e984683ac1
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1fa1515aae810e588f2c1d25728755c25fd51c78d35d96d823583052372c34dd
50c609ebf387b400a01ea28e6ccf0f3de2ff7a5eedbb47c0a4bc40d05e3aaade
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
69eaf24b0ed3260a6b3656563b1d0b8b268de81899f05bc984c668b0765e361f
c2350d0277636285b182d1484d01d706c3116ef5e28917ddeb24ca78bac251ba
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c6bd0a45a6cc3b09114be55c8b63563bea4ca6b748ee27c7843bb4ab05d7ce26
3aaa2a7655189a1e4da55a86fb30fdfde29a5b6ad65de53a78b18df30eeb97f3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f870210add7c920df42e276a4cefcd1eaf01b3869f238c0a366beeb052e4adc3
975ead22afb4c5bc5c79799b76c1d3a683b572b906e3bade94944319920f7f84
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3a3c0d4e5a1564eaa2ca35b698cd4b553722476cf68995495b9f277631b08266
b849aeca8e5d6b6ede216e372cfc019bac08774006e11ed81d272f67770adc17
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
aab9d674d5d94b7488fe088e5c355511403edf7878c6fe124e2a2be86598ead6
79505f2677e5f19374a3ed46cccdc1ed8d3092a3c43e3154e8cfd73063533251
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
391ce453aa40de84ca4a6ed5f5b8ee88f5d659e8699029fa3a8940dac7d6dc9b
f7f43d46ebd545cc4c52d859c4307d08bb95b3187a4b947704e37c27cf0f4fa0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ccbb5abb90a0bb49c0f51d0e2865d1c4092e28e9168e4c57e5f7b6ea971721ac
491e8e713e51045ebef81c4f07ed5475ed41688e88ac47ee26d13f2bc5c53b49
-336
Переглянути файл
@@ -1,336 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen",
]
[[package]]
name = "openidea-test-lsp-requests"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-20
Переглянути файл
@@ -1,20 +0,0 @@
[package]
name = "openidea-test-lsp-requests"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
-1
Переглянути файл
@@ -1 +0,0 @@
4788f82b35966e155dbf51af520acb2d5e7773ddf9f36f4239b9bd51d51b0fb3
-24
Переглянути файл
@@ -1,24 +0,0 @@
id = "test.lsp-requests"
name = "Language server requests fixture"
version = "0.1.0"
engines.openidea = "^1.9"
wasm = "dist/plugin.wasm"
[capabilities]
optional = ["lsp:mock"]
[[contributes.commands]]
id = "request"
title = "Ask the mock server for its routes"
[[contributes.commands]]
id = "notify"
title = "Tell the mock server something changed"
[[contributes.commands]]
id = "standard"
title = "Send a method of LSP itself"
[[contributes.commands]]
id = "ungranted"
title = "Ask a server the manifest does not name"
-19
Переглянути файл
@@ -1,19 +0,0 @@
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
#[derive(Default)]
struct LspRequests;
impl Plugin for LspRequests {
fn run_command(&mut self, id: &str, inv: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
let project = inv.project().ok_or("no project")?;
match id {
"request" => project.language_server_request("mock", "mock/routes", arg).map(Some),
"notify" => project.language_server_notify("mock", "mock/changed", arg).map(|()| None),
"standard" => project.language_server_request("mock", "textDocument/hover", arg).map(Some),
"ungranted" => project.language_server_request("other", "other/status", arg).map(Some),
_ => Err(Error::NotFound(id.into())),
}
}
}
register_plugin!(LspRequests);
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6e1d2ff774cc8088b4b8a6766914a7f752d05657634e6934503cebd2aa241fa2
63b2f6007782c9d3498c86505a6c107b70f09d90678c79cf16c859ecbf349e70
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a11bc7b7151a62e98d665a6459b3c84e4cb836b734fd23e7bad3adc533655f81
3d1f2451dee8bba3cc874f497bcea909fa9bd58ae605dc1d74b4298b4b11ed94
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
aad4871073c7c7f22ab84f1e7965d1a861b7e5ddd9f86408e95eba4c3c021411
26a2b15b9354fa0ba2bf5934114bfdd20f7906c4a67497d013146cc6b3fc9643
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f2e0df07f6d7a501e96caf43efcfdaa0596aa2b9dad5c46ef71f5ad2eddc505b
c9ef2cf7b6e8f3204f8ee382d7a381a5190b1e812e11d51bd1262c3d429a315f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
253f93d1254310669150f6fd629ae88223d7d6f1cd642803ec57382e5acd4451
b07cc854c1302fd5bfd29fb3037732c7439efde606fc57295b2c0f94602a1077
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
9351d3248252ca35a40ce557ada29e8aa4f3135273e988235bc22ebcfd0505ec
12c4004f118070d0f37125ac32639ab9f332d59cc4585e74a8af61e3e6c5c1e8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7f4ad5bc15d70de0ae7a0e96c584edcd6caef1cb18b5450685a83d8a8de16d24
7517da988ab8cacaa3abf7645274a84e364de582ea9edf60a63926b1442d1581
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7d18591aa915fd6dd71fd8f8a4a0c7d8abb2cc5ff9252a56fb0d6b7458562466
6e045ee3fcaa655bae773774e48e3eeb9d97ae9d8db1f52bf76c38459dcecb3f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
55e59a27457e8f25e9bd0e727ebbc6276b0f45d210732a88ede4954ec2dc777f
69bb92e6e24e54458978a316c465d17b93d9ce9613cd26d7d6de9a1cef9b9447
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8ce7d4a12f8bd393b8b32e695d3dd2994c30ba27fb8419b8af900a550cb2da7c
fc64a30e9d3b46275a6480641454b367b5082aea82da23a11b57106aa02387e1
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f654b73781d88c25dddddcdb25e8c02195b36dc9e008dd9eafafd632a11c48c5
e6f3b5a4d81e62240fa1de4d1624b3706f145d47c014a1a2ed2b899cab24e0e4
-1
Переглянути файл
@@ -1306,7 +1306,6 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
}),
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
-82
Переглянути файл
@@ -25,7 +25,6 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec<Sdk> {
SdkKind::Jdk => jdk(),
SdkKind::Maven => maven(),
SdkKind::Ruby => crate::ruby::detect(root),
SdkKind::Zig => zig(),
};
if let Some(root) = root {
pinned_first(&mut out, kind, root);
@@ -41,7 +40,6 @@ fn tool_of(kind: SdkKind) -> Option<Tool> {
SdkKind::Jdk => Some(Tool::Java),
SdkKind::Maven => Some(Tool::Maven),
SdkKind::Ruby => Some(Tool::Ruby),
SdkKind::Zig => Some(Tool::Zig),
SdkKind::Php | SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None,
}
}
@@ -741,86 +739,6 @@ fn go() -> Vec<Sdk> {
})
}
// ------------------------------------------------------------------ zig
/// The directory holding the `zig` executable of the install rooted at `base`,
/// the way the common distributions lay it out: `zig` at the root (official
/// tarballs, mise / asdf installs), `<base>/bin/zig` (Homebrew) or one
/// directory down (zvm's nested version folders).
fn zig_dir(base: &Path) -> Option<PathBuf> {
if base.join(exe("zig")).is_file() {
return Some(base.to_path_buf());
}
let bin = base.join(bin_dir());
if bin.join(exe("zig")).is_file() {
return Some(bin);
}
subdirs(base).into_iter().find(|d| d.join(exe("zig")).is_file())
}
/// Zig toolchains: PATH, Homebrew, zvm and mise / asdf installs. `path` is the
/// directory holding `zig`, which is what `languages.zig.zig` stores.
fn zig() -> Vec<Sdk> {
let mut roots: Vec<(PathBuf, &'static str)> = Vec::new();
if let Some(bin) = which("zig").filter(|p| !version_managers::is_shim(p))
&& let Some(dir) = bin.parent()
{
roots.push((dir.to_path_buf(), "path"));
}
for p in ["/opt/homebrew/opt/zig", "/usr/local/opt/zig"] {
roots.push((PathBuf::from(p), "system"));
}
if let Some(home) = home_dir() {
for d in subdirs(&home.join(".zvm")) {
roots.push((d, "zvm"));
}
}
for (d, manager) in version_managers::installs(Tool::Zig) {
roots.push((d, manager));
}
let mut dirs: Vec<(PathBuf, &'static str)> =
roots.into_iter().filter_map(|(base, source)| zig_dir(&base).map(|dir| (dir, source))).collect();
let mut seen = HashSet::new();
dirs.retain(|(dir, _)| seen.insert(dir.canonicalize().unwrap_or_else(|_| dir.clone())));
par_map(&dirs, |(dir, source)| {
let version = query(&dir.join(exe("zig")), &["version"], VERSION_TIMEOUT).map(|v| v.trim().to_owned());
Some(Sdk {
path: dir.clone().into(),
name: format!("Zig {}", version.as_deref().unwrap_or_default()).trim().to_owned(),
version,
source: (*source).to_owned(),
home: Some(dir.clone().into()),
extra: None,
})
})
}
#[cfg(test)]
mod zig_tests {
use super::*;
#[test]
fn finds_the_zig_directory_of_each_layout() {
let tmp = tempfile::tempdir().unwrap();
let install = |rel: &str| {
let dir = tmp.path().join(rel);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(exe("zig")), "").unwrap();
dir
};
// Official tarballs and mise / asdf installs: `zig` at the root.
let root = install("0.14.1");
assert_eq!(zig_dir(&root), Some(root));
// Homebrew: <opt>/bin/zig.
let brew = install("brew/bin");
assert_eq!(zig_dir(brew.parent().unwrap()), Some(brew));
// zvm: one directory down.
let zvm = install("zvm/x");
assert_eq!(zig_dir(&tmp.path().join("zvm")), Some(zvm));
assert_eq!(zig_dir(&tmp.path().join("missing")), None);
}
}
// ------------------------------------------------------------------ typescript
/// `node_modules/typescript/lib` of the project (root or a direct workspace member).
-2
Переглянути файл
@@ -52,8 +52,6 @@ pub enum SdkKind {
/// Maven homes.
Maven,
Ruby,
/// Zig toolchain directories.
Zig,
}
/// One installed interpreter / toolchain.
Loaded 100 of 455 files, more files were not shown because too many files have changed in this diff. Show more