mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1446d5592c | ||
|
|
fbf57bed23 | ||
|
|
292ddc4d2a | ||
|
|
af19880390 | ||
|
|
30a0521d0d | ||
|
|
4a78310f76 | ||
|
|
7cedf0b9be | ||
|
|
0bee9b7aab | ||
|
|
3efec1c848 | ||
|
|
e024c13db7 | ||
|
|
780e57d0c5 | ||
|
|
200ab10e65 | ||
|
|
dfcdd8d764 | ||
|
|
27e73ea2cb | ||
|
|
bb99c7c4d1 | ||
|
|
f6531542de | ||
|
|
9e6cb7b93e | ||
|
|
0b88599463 | ||
|
|
da3d996e10 | ||
|
|
4c6e535560 | ||
|
|
705f3557a8 | ||
|
|
4dcf7b0fb2 | ||
|
|
a6d24e684f | ||
|
|
1b889a5147 | ||
|
|
4d088c35ff | ||
|
|
60d7dcdca0 | ||
|
|
7479b7ad9d | ||
|
|
053a2c37d8 | ||
|
|
a40a909db5 | ||
|
|
c1e32b091a | ||
|
|
72b39d819f | ||
|
|
b790a9ddb6 |
No files matched your search
@@ -1692,12 +1692,17 @@ impl Task {
|
||||
/// Blocks the calling (blocking-pool) thread until the user answers.
|
||||
fn ask_permission(self: &Arc<Self>, params: &Value) -> Value {
|
||||
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
|
||||
let (questions, by_index) = asked_questions(params);
|
||||
let title = params
|
||||
.pointer("/toolCall/title")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or("The agent asks for permission")
|
||||
.to_owned();
|
||||
// An agent that answers by index titles its request "Ask user N question(s)"; its
|
||||
// first question says what it wants, as the Claude Code adapter titles its own.
|
||||
let title =
|
||||
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
|
||||
// A subagent's request is answered in the task's conversation, named after the subagent.
|
||||
let session = params.get("sessionId").and_then(Value::as_str);
|
||||
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
|
||||
@@ -1719,10 +1724,6 @@ impl Task {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let questions: Vec<AgentQuestion> = params
|
||||
.pointer("/toolCall/_meta/questions")
|
||||
.and_then(|questions| serde_json::from_value(questions.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let entry =
|
||||
@@ -1736,7 +1737,7 @@ impl Task {
|
||||
.as_deref()
|
||||
.filter(|_| !asked.is_empty())
|
||||
.and_then(|reply| serde_json::from_str::<serde_json::Map<String, Value>>(reply).ok());
|
||||
let name = match answers {
|
||||
let name = match &answers {
|
||||
Some(answers) => asked
|
||||
.iter()
|
||||
.filter_map(|question| answers.get(question))
|
||||
@@ -1762,6 +1763,31 @@ impl Task {
|
||||
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
|
||||
self.set_status(AgentStatus::Running, None);
|
||||
}
|
||||
// An agent that answers by index reads the answers beside the outcome, keyed by
|
||||
// question index, with its Submit option chosen; anything less cancels the request.
|
||||
if by_index {
|
||||
let submit = options.iter().find(|option| option.kind == "allow_once").or_else(|| options.first());
|
||||
return match (answers.as_ref(), submit) {
|
||||
(Some(answers), Some(submit)) => {
|
||||
let picked: serde_json::Map<String, Value> = asked
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, question)| {
|
||||
let items = answers.get(question)?;
|
||||
let answer = match items {
|
||||
Value::Array(items) => {
|
||||
items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
item => item.as_str().unwrap_or_default().to_owned(),
|
||||
};
|
||||
Some((index.to_string(), json!(answer)))
|
||||
})
|
||||
.collect();
|
||||
json!({ "outcome": { "outcome": "selected", "optionId": submit.id }, "answers": picked })
|
||||
}
|
||||
_ => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
};
|
||||
}
|
||||
match answer {
|
||||
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }),
|
||||
None => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
@@ -1775,7 +1801,10 @@ impl Task {
|
||||
}
|
||||
let mut pending = self.permissions.lock();
|
||||
for entry in s.conversation.main.entries.iter().rev() {
|
||||
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
|
||||
// A request with questions is never satisfied by a bypass: its Submit option
|
||||
// would answer with no answers.
|
||||
if let AgentEntry::Permission { id, options, questions, chosen: None, .. } = entry
|
||||
&& questions.is_empty()
|
||||
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
|
||||
&& let Some(reply) = pending.remove(id)
|
||||
{
|
||||
@@ -1840,6 +1869,23 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
|
||||
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
|
||||
}
|
||||
|
||||
/// The questions a permission request asks, and whether they answer by question index.
|
||||
/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id;
|
||||
/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside
|
||||
/// the id of its Submit option.
|
||||
fn asked_questions(params: &Value) -> (Vec<AgentQuestion>, bool) {
|
||||
for (pointer, by_index) in [("/toolCall/_meta/questions", false), ("/toolCall/_meta/qwenQuestions", true)] {
|
||||
if let Some(questions) = params
|
||||
.pointer(pointer)
|
||||
.and_then(|questions| serde_json::from_value::<Vec<AgentQuestion>>(questions.clone()).ok())
|
||||
.filter(|questions| !questions.is_empty())
|
||||
{
|
||||
return (questions, by_index);
|
||||
}
|
||||
}
|
||||
(Vec::new(), false)
|
||||
}
|
||||
|
||||
/// ACP content blocks: the text, each file (embedded when its text is in `contents`, else
|
||||
/// a `resource_link`), then the images.
|
||||
fn blocks(prompt: &AgentPrompt, contents: &[Option<String>]) -> Vec<Value> {
|
||||
|
||||
@@ -9,6 +9,7 @@ struct FakeAgent {
|
||||
/// Every connection's agent side, kept open.
|
||||
servers: Mutex<Vec<Peer>>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
permission_replies: Mutex<Vec<Value>>,
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
auth_on_new: bool,
|
||||
@@ -191,9 +192,37 @@ impl Handler for FakeAgent {
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
self.permission_replies.lock().push(answer.clone());
|
||||
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
self.say(&session, &format!("answered {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
return Ok(json!({ "stopReason": "end_turn" }));
|
||||
}
|
||||
if text == "qwen ask" {
|
||||
// Qwen Code's ACP dialect: the questions as `qwenQuestions`, Submit and
|
||||
// Cancel as the options, the answers keyed by question index.
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session,
|
||||
"toolCall": {"toolCallId": "q2", "title": "Ask user 2 questions", "kind": "think",
|
||||
"_meta": {"toolName": "ask_user_question", "qwenInteractionKind": "user_question",
|
||||
"qwenQuestions": [
|
||||
{"question": "Which database?", "header": "Database", "options": [
|
||||
{"label": "Postgres", "description": "Relational"},
|
||||
{"label": "Redis", "description": "Key-value"}]},
|
||||
{"question": "Which caches?", "header": "Caches", "multiSelect": true,
|
||||
"options": [{"label": "Disk"}, {"label": "Memory"}]}]}},
|
||||
"options": [{"optionId": "proceed_once", "name": "Submit", "kind": "allow_once"},
|
||||
{"optionId": "cancel", "name": "Cancel", "kind": "reject_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let outcome = answer.pointer("/outcome/outcome").and_then(Value::as_str).unwrap_or("none");
|
||||
let option = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
let answers = answer.get("answers").cloned().unwrap_or(Value::Null);
|
||||
self.say(&session, &format!("answered {outcome} {option} {answers}"));
|
||||
return Ok(json!({ "stopReason": "end_turn" }));
|
||||
}
|
||||
if text == "wait" {
|
||||
// Works until cancelled.
|
||||
@@ -692,9 +721,10 @@ fn prompts_become_acp_content_blocks() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn answered_questions_close_the_question_with_every_picked_label() {
|
||||
async fn claude_questions_keep_the_answer_object_as_the_option_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
@@ -712,13 +742,22 @@ async fn answered_questions_close_the_question_with_every_picked_label() {
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, .. } => Some(*id),
|
||||
AgentEntry::Permission { id, title, questions, .. } => {
|
||||
assert_eq!(title, "AskUserQuestion");
|
||||
assert_eq!(questions.len(), 2);
|
||||
assert_eq!(questions[0].question, "Which layout?");
|
||||
assert!(!questions[0].multi_select);
|
||||
assert_eq!(questions[1].question, "Which checks?");
|
||||
assert!(questions[1].multi_select);
|
||||
Some(*id)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
|
||||
mgr.respond(&task.id, question, Some(reply)).unwrap();
|
||||
mgr.respond(&task.id, question, Some(reply.clone())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert_eq!(*agent.permission_replies.lock(), [json!({"outcome": {"outcome": "selected", "optionId": reply}})]);
|
||||
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(
|
||||
@@ -729,6 +768,113 @@ async fn answered_questions_close_the_question_with_every_picked_label() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Qwen Code asks its questions as `_meta.qwenQuestions` with Submit/Cancel options; the
|
||||
/// card answers with `answers` keyed by question index beside the Submit option's id.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn qwen_questions_answer_by_index_beside_the_submit_option() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("qwen ask"),
|
||||
provider: "codex".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let (id, title, questions) = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, title, questions, .. } => Some((*id, title.clone(), questions.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(title, "Which database?", "the first question, not the request's own title");
|
||||
assert_eq!(
|
||||
questions,
|
||||
vec![
|
||||
AgentQuestion {
|
||||
header: "Database".into(),
|
||||
question: "Which database?".into(),
|
||||
multi_select: false,
|
||||
options: vec![
|
||||
AgentQuestionOption { label: "Postgres".into(), description: "Relational".into(), preview: None },
|
||||
AgentQuestionOption { label: "Redis".into(), description: "Key-value".into(), preview: None },
|
||||
],
|
||||
},
|
||||
AgentQuestion {
|
||||
header: "Caches".into(),
|
||||
question: "Which caches?".into(),
|
||||
multi_select: true,
|
||||
options: vec![
|
||||
AgentQuestionOption { label: "Disk".into(), description: String::new(), preview: None },
|
||||
AgentQuestionOption { label: "Memory".into(), description: String::new(), preview: None },
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
let reply = json!({"Which database?": ["Postgres"], "Which caches?": ["Disk", "Memory"]}).to_string();
|
||||
mgr.respond(&task.id, id, Some(reply)).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let expected = format!("answered selected proceed_once {}", json!({"0": "Postgres", "1": "Disk, Memory"}));
|
||||
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == &expected)), "{entries:?}");
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Postgres; Disk, Memory")),
|
||||
"{entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A skipped Qwen question cancels the request: qwen-code reads the answers it expects
|
||||
/// only beside a chosen option, and `reject` is none of the options it offered.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn skipped_qwen_questions_cancel_the_request() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("qwen ask"),
|
||||
provider: "codex".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let id = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, questions, .. } if !questions.is_empty() => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
mgr.respond(&task.id, id, Some("reject".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "answered cancelled none null")),
|
||||
"{entries:?}"
|
||||
);
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")),
|
||||
"{entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1222,6 +1368,64 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permissions() {
|
||||
for prompt in ["ask", "qwen ask"] {
|
||||
for bypass_at_start in [false, true] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
// Cursor's client-side approval policy applies independently of the question dialect.
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text(prompt),
|
||||
provider: "cursor".into(),
|
||||
config: if bypass_at_start {
|
||||
vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let id = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|entry| match entry {
|
||||
AgentEntry::Permission { id, questions, chosen: None, .. } if questions.len() == 2 => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
|
||||
assert!(mgr.task(&task.id).unwrap().permissions.lock().contains_key(&id), "{prompt}, {bypass_at_start}");
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::NeedsInput);
|
||||
let reply = if prompt == "ask" {
|
||||
json!({"Which layout?": ["Grid"], "Which checks?": ["Disk"]})
|
||||
} else {
|
||||
json!({"Which database?": ["Postgres"], "Which caches?": ["Memory"]})
|
||||
};
|
||||
mgr.respond(&task.id, id, Some(reply.to_string())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
|
||||
mgr.prompt(&task.id, text("Fix"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(
|
||||
entries.iter().any(|entry| matches!(entry,
|
||||
AgentEntry::Permission { questions, chosen: Some(name), .. }
|
||||
if questions.is_empty() && name == "Allow")),
|
||||
"{entries:?}"
|
||||
);
|
||||
assert!(entries.iter().any(|entry| matches!(entry, AgentEntry::Agent { text } if text == "chose yes")));
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -153,7 +153,7 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
} else if has(dir, &["go.mod"]) {
|
||||
} else if has(dir, &["go.mod", "go.work"]) {
|
||||
Some(BuildSystem::Go)
|
||||
} else if has(dir, &["tsconfig.json", "package.json"]) {
|
||||
Some(BuildSystem::TypeScript)
|
||||
@@ -227,7 +227,7 @@ pub fn plan(
|
||||
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
|
||||
BuildSystem::Cargo => cargo_steps(&dir, kind),
|
||||
// Go builds the file's package (a module holds many).
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
|
||||
BuildSystem::TypeScript => ts_steps(&dir, kind),
|
||||
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
|
||||
};
|
||||
@@ -374,7 +374,31 @@ fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> V
|
||||
steps
|
||||
}
|
||||
|
||||
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
fn go_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
|
||||
if dir.join("go.work").is_file() {
|
||||
let work_file = dunce::canonicalize(dir.join("go.work"))?;
|
||||
let modules = ide_core::go::work_modules(&std::fs::read_to_string(dir.join("go.work"))?);
|
||||
if modules.is_empty() {
|
||||
return Err(IdeError::invalid("The Go workspace contains no modules"));
|
||||
}
|
||||
let mut steps = Vec::new();
|
||||
for module in modules {
|
||||
let module = dir.join(module);
|
||||
if !module.join("go.mod").is_file() {
|
||||
return Err(IdeError::not_found(format!("Go workspace module has no go.mod: {}", module.display())));
|
||||
}
|
||||
let mut module_steps = go_module_steps(&module, kind);
|
||||
for step in &mut module_steps {
|
||||
step.env.insert("GOWORK".into(), work_file.to_string_lossy().into_owned());
|
||||
}
|
||||
steps.extend(module_steps);
|
||||
}
|
||||
return Ok(steps);
|
||||
}
|
||||
Ok(go_module_steps(dir, kind))
|
||||
}
|
||||
|
||||
fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
|
||||
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
|
||||
let mut args = vec!["build"];
|
||||
@@ -813,6 +837,68 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn go_workspace_build_commands_compile_members_with_local_dependencies() {
|
||||
let Some(go) = ide_core::process::find_executable("go", &ide_core::tool_path::search_path()) else {
|
||||
return;
|
||||
};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = &tmp.path().join("workspace");
|
||||
write(root, "go.work", "go 1.22\nuse (\n ./app\n ./lib\n ../external\n)\n");
|
||||
write(root, "lib/go.mod", "module example.com/lib\ngo 1.22\n");
|
||||
write(root, "lib/lib.go", "package lib\nfunc Value() int { return 1 }\n");
|
||||
write(root, "app/go.mod", "module example.com/app\ngo 1.22\nrequire example.com/lib v0.0.0\n");
|
||||
write(root, "app/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
|
||||
write(root, "../external/go.mod", "module example.com/external\ngo 1.22\nrequire example.com/lib v0.0.0\n");
|
||||
write(root, "../external/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
|
||||
let plan = plan(root, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
for step in plan.steps {
|
||||
let result = std::process::Command::new(&go)
|
||||
.args(&step.args)
|
||||
.envs(&step.env)
|
||||
.env("GOPROXY", "off")
|
||||
.current_dir(&step.cwd)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_workspace_builds_every_use_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("workspace");
|
||||
write(&root, "go.work", "go 1.22\nuse (\n ./api\n \"./lib with spaces\"\n ../shared\n)\n");
|
||||
for module in ["api", "lib with spaces", "../shared"] {
|
||||
write(&root.join(module), "go.mod", "module example.com/p\ngo 1.22\n");
|
||||
}
|
||||
for kind in [BuildKind::Build, BuildKind::Rebuild, BuildKind::Artifacts] {
|
||||
let p = plan(&root, &BuildModel::default(), kind, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Go);
|
||||
assert_eq!(p.steps.len(), 3);
|
||||
for (step, module) in p.steps.iter().zip(["api", "lib with spaces", "../shared"]) {
|
||||
assert_eq!(step.cwd, root.join(module));
|
||||
let expected =
|
||||
if kind == BuildKind::Rebuild { vec!["build", "-a", "./..."] } else { vec!["build", "./..."] };
|
||||
assert_eq!(step.args, expected);
|
||||
}
|
||||
}
|
||||
let member =
|
||||
plan(&root, &BuildModel::default(), BuildKind::Build, Some(&root.join("api/pkg/a.go")), None).unwrap();
|
||||
assert_eq!(member.steps.len(), 1);
|
||||
assert_eq!(member.steps[0].cwd, root.join("api"));
|
||||
assert_eq!(member.steps[0].args, ["build", "./pkg/..."]);
|
||||
write(&root, "go.work", "use ./missing\n");
|
||||
assert!(
|
||||
plan(&root, &BuildModel::default(), BuildKind::Build, None, None)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("go.mod")
|
||||
);
|
||||
write(&root, "go.work", "go 1.22\n");
|
||||
assert!(plan(&root, &BuildModel::default(), BuildKind::Build, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Go workspace module paths shared by project builds and run detection.
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
|
||||
pub fn work_modules(text: &str) -> Vec<PathBuf> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\n' | '(' | ')' => tokens.push(ch.to_string()),
|
||||
ch if ch.is_whitespace() => {}
|
||||
'/' if chars.peek() == Some(&'/') => {
|
||||
for c in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
tokens.push("\n".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' | '`' => {
|
||||
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
|
||||
tokens.push(token);
|
||||
}
|
||||
_ => {
|
||||
let mut token = ch.to_string();
|
||||
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
|
||||
token.push(chars.next().unwrap());
|
||||
}
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut start = true;
|
||||
let mut tokens = tokens.into_iter();
|
||||
while let Some(token) = tokens.next() {
|
||||
if token == "\n" {
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
if start
|
||||
&& token == "use"
|
||||
&& let Some(path) = tokens.next()
|
||||
{
|
||||
if path == "(" {
|
||||
let mut paths = Vec::new();
|
||||
let mut closed = false;
|
||||
for path in tokens.by_ref() {
|
||||
if path == ")" {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if path != "\n" && !path.is_empty() {
|
||||
paths.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
out.extend(paths);
|
||||
}
|
||||
} else if path != "\n" && !path.is_empty() {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
start = false;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|path| seen.insert(path.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == quote {
|
||||
return String::from_utf8(bytes).ok();
|
||||
}
|
||||
if ch == '\\' && quote == '"' {
|
||||
let escaped = chars.next()?;
|
||||
let byte = match escaped {
|
||||
'a' => 7,
|
||||
'b' => 8,
|
||||
'f' => 12,
|
||||
'n' => 10,
|
||||
'r' => 13,
|
||||
't' => 9,
|
||||
'v' => 11,
|
||||
'\\' => b'\\',
|
||||
'"' => b'"',
|
||||
'x' => {
|
||||
let digits: String = chars.by_ref().take(2).collect();
|
||||
if digits.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 16).ok()?
|
||||
}
|
||||
'u' | 'U' => {
|
||||
let width = if escaped == 'u' { 4 } else { 8 };
|
||||
let digits: String = chars.by_ref().take(width).collect();
|
||||
if digits.len() != width {
|
||||
return None;
|
||||
}
|
||||
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
continue;
|
||||
}
|
||||
'0'..='7' => {
|
||||
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
|
||||
if digits.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 8).ok()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
bytes.push(byte);
|
||||
} else {
|
||||
if ch == '\n' && quote == '"' {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod editor_settings;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod go;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod inspection_settings;
|
||||
|
||||
@@ -443,7 +443,7 @@ pub fn compose_op(
|
||||
|
||||
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
|
||||
/// is this machine's folder. The CLI's default daemon and a context are looked
|
||||
/// up (`DOCKER_HOST`, else the context's endpoint).
|
||||
/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context).
|
||||
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
|
||||
let url = match ep {
|
||||
DockerEndpoint::Socket { path } => path.trim().to_owned(),
|
||||
@@ -451,17 +451,31 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
|
||||
let host = host.trim();
|
||||
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
|
||||
}
|
||||
DockerEndpoint::Default => {
|
||||
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
|
||||
Some(url) if !url.is_empty() => url,
|
||||
_ => context_host(ep, None)?,
|
||||
}
|
||||
}
|
||||
DockerEndpoint::Default => default_daemon_url(
|
||||
ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()),
|
||||
ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()),
|
||||
|name| context_host(&DockerEndpoint::Default, name),
|
||||
)?,
|
||||
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
|
||||
};
|
||||
Ok(daemon_url_is_local(&url))
|
||||
}
|
||||
|
||||
fn default_daemon_url(
|
||||
context: Option<&str>,
|
||||
host: Option<&str>,
|
||||
lookup: impl FnOnce(Option<&str>) -> IdeResult<String>,
|
||||
) -> IdeResult<String> {
|
||||
let context = context.filter(|v| !v.is_empty());
|
||||
if context.is_some() {
|
||||
return lookup(context);
|
||||
}
|
||||
match host.filter(|v| !v.is_empty()) {
|
||||
Some(host) => Ok(host.to_owned()),
|
||||
None => lookup(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon URL of context `name` (the current one with `None`).
|
||||
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
|
||||
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
|
||||
@@ -506,6 +520,34 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
|
||||
mod local_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_selected_context_overrides_docker_host() {
|
||||
let local = "unix:///var/run/docker.sock";
|
||||
let remote = "ssh://me@build";
|
||||
for host in [None, Some(""), Some(local)] {
|
||||
let url = default_daemon_url(Some("build"), host, |name| {
|
||||
assert_eq!(name, Some("build"));
|
||||
Ok(remote.into())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!daemon_url_is_local(&url));
|
||||
}
|
||||
for context in [None, Some("")] {
|
||||
assert_eq!(
|
||||
default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(),
|
||||
local
|
||||
);
|
||||
assert_eq!(
|
||||
default_daemon_url(context, None, |name| {
|
||||
assert_eq!(name, None);
|
||||
Ok(remote.into())
|
||||
})
|
||||
.unwrap(),
|
||||
remote
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_daemon_urls() {
|
||||
for url in [
|
||||
|
||||
@@ -78,6 +78,11 @@ async fn run(base: PathBuf) -> i32 {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV)
|
||||
&& let Err(e) = host.trust.inherit(std::path::Path::new(&root))
|
||||
{
|
||||
tracing::warn!("cannot trust {}: {e}", root.to_string_lossy());
|
||||
}
|
||||
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
if let Some(plugins) = &plugins {
|
||||
|
||||
@@ -22,6 +22,10 @@ use std::time::Duration;
|
||||
|
||||
type Changes = Vec<(OsString, Option<OsString>)>;
|
||||
|
||||
/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands
|
||||
/// (`devcontainer up`): the server then doesn't run `postStartCommand` again.
|
||||
pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE";
|
||||
|
||||
/// What Reload Environment changed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -72,7 +76,7 @@ impl Environment {
|
||||
return None;
|
||||
}
|
||||
let config = self.load_project(root, sink);
|
||||
let post_start = config.and_then(|c| c.post_start);
|
||||
let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none());
|
||||
let first = self.state.lock().post_started.insert(root.to_path_buf());
|
||||
post_start.filter(|_| first)
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ impl Host {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
project.plugin_diagnostics.close();
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), None);
|
||||
plugins.set_active_file(window, None);
|
||||
@@ -373,6 +374,7 @@ impl Host {
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in &projects {
|
||||
project.plugin_diagnostics.close();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
|
||||
@@ -561,7 +561,8 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
|
||||
file_action: r.file,
|
||||
};
|
||||
let plugin = r.plugin.clone();
|
||||
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
|
||||
let invocation = ide_plugin::Invocation { client: Some(window.to_owned()), ..Default::default() };
|
||||
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending, invocation)).await?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
}
|
||||
@@ -586,7 +587,7 @@ pub async fn lsp_code_action_query(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Resolves a code action's edit without applying it (intention preview).
|
||||
/// Resolves a code action's edit without applying it.
|
||||
pub async fn lsp_resolve_code_action(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
|
||||
@@ -181,6 +181,16 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_installations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
) -> IdeResult<Vec<ide_run::tomcat::TomcatInstallation>> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(ide_run::tomcat::detect_installations)
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
@@ -212,7 +222,7 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
|
||||
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
|
||||
)?;
|
||||
plan.env.extend(config.env.clone());
|
||||
let mut build = RunConfig {
|
||||
@@ -278,7 +288,7 @@ pub async fn run_tomcat_sync(
|
||||
let module = dunce::canonicalize(project.root.join(module))?;
|
||||
let class_paths: Vec<_> =
|
||||
class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect();
|
||||
let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id);
|
||||
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
|
||||
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
|
||||
})
|
||||
.await
|
||||
@@ -297,6 +307,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await
|
||||
});
|
||||
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
|
||||
d.add("run_tomcat_installations", |c| async move { run_tomcat_installations(&c.host, &c.window).await });
|
||||
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
|
||||
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("run_tomcat_sync", |c| async move {
|
||||
|
||||
@@ -8,11 +8,13 @@ use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
use ide_plugin::{
|
||||
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
|
||||
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
|
||||
ProcessChunk, ProcessCommand, ProcessOutput, Prompt, RepositoryStatus, StatusItem,
|
||||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command as StdCommand, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -49,6 +51,17 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
|
||||
None
|
||||
}
|
||||
/// Current index of the project rooted at `root`; never reads disk or calls LSP.
|
||||
fn symbols(
|
||||
&self,
|
||||
_root: &Path,
|
||||
_query: &str,
|
||||
_limit: usize,
|
||||
_cancel: &CancellationToken,
|
||||
_deadline: Instant,
|
||||
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
|
||||
Err(HostError::NotFound("no project index is available".into()))
|
||||
}
|
||||
/// Version and text of the open document at `path`.
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)>;
|
||||
/// Applies `edits` to the open document at `path` if it is still at
|
||||
@@ -124,6 +137,10 @@ impl SecretStore for FileSecretStore {
|
||||
const MAX_RESPONSE: usize = 8 << 20;
|
||||
/// Largest stdout / stderr kept from a process.
|
||||
const MAX_OUTPUT: usize = 4 << 20;
|
||||
/// Most processes one plugin keeps running at once.
|
||||
const MAX_PROCESSES: usize = 8;
|
||||
/// Most output one stream of a handle holds until the plugin reads it.
|
||||
const MAX_PENDING: usize = 1 << 20;
|
||||
/// Most files `find-files` walks before giving up.
|
||||
const MAX_WALK: usize = 200_000;
|
||||
|
||||
@@ -167,6 +184,7 @@ struct TaskEntry {
|
||||
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
|
||||
struct PromptEntry {
|
||||
plugin: String,
|
||||
client: String,
|
||||
answer: Waiter,
|
||||
}
|
||||
|
||||
@@ -175,6 +193,218 @@ enum Waiter {
|
||||
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
|
||||
}
|
||||
|
||||
/// A process a plugin started and has not released (API 1.3 `process.start`).
|
||||
/// Two reader threads copy its output into bounded buffers; the handle lives
|
||||
/// as long as the plugin instance that started it.
|
||||
struct Proc {
|
||||
client: Option<String>,
|
||||
state: Mutex<ProcState>,
|
||||
}
|
||||
|
||||
struct ProcState {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
/// What it printed and the plugin hasn't read yet.
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
/// Its exit status once it ended (a code, or -1 when a signal ended it).
|
||||
status: Option<i32>,
|
||||
/// Reader threads still copying its pipes into the buffers above.
|
||||
readers: usize,
|
||||
}
|
||||
|
||||
/// Which pipe a reader thread drains.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Stream {
|
||||
Out,
|
||||
Err,
|
||||
}
|
||||
|
||||
impl Proc {
|
||||
/// Copies `pipe` into the matching buffer until it closes, keeping the last
|
||||
/// [`MAX_PENDING`] bytes, then notes that the reader finished.
|
||||
fn read(self: &Arc<Self>, mut pipe: impl Read + Send + 'static, stream: Stream) {
|
||||
self.state.lock().readers += 1;
|
||||
let this = Arc::clone(self);
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8 << 10];
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let mut state = this.state.lock();
|
||||
let out = match stream {
|
||||
Stream::Out => &mut state.stdout,
|
||||
Stream::Err => &mut state.stderr,
|
||||
};
|
||||
out.extend_from_slice(&buf[..n]);
|
||||
let over = out.len().saturating_sub(MAX_PENDING);
|
||||
if over > 0 {
|
||||
out.drain(..over);
|
||||
let continuation = out.iter().take_while(|b| (**b & 0xC0) == 0x80).count();
|
||||
out.drain(..continuation);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
this.state.lock().readers -= 1;
|
||||
});
|
||||
}
|
||||
|
||||
/// Notes its exit status if it ended (dropping its stdin then).
|
||||
fn reap(&self) -> Result<(), HostError> {
|
||||
let mut state = self.state.lock();
|
||||
if state.status.is_none() {
|
||||
match try_wait_process(&mut state.child) {
|
||||
Ok(Some(status)) => {
|
||||
state.status = Some(status.code().unwrap_or(-1));
|
||||
state.stdin = None;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends it and reaps it, so its status is known right away; killing twice
|
||||
/// does nothing.
|
||||
fn kill(&self) {
|
||||
let mut state = self.state.lock();
|
||||
state.stdin = None;
|
||||
if state.status.is_none() {
|
||||
ide_core::process::kill_tree(&mut state.child);
|
||||
if let Ok(status) = state.child.wait() {
|
||||
state.status = Some(status.code().unwrap_or(-1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What it printed since the last call, each stream at most `max` bytes.
|
||||
fn chunk(&self, max: usize) -> ProcessChunk {
|
||||
let mut state = self.state.lock();
|
||||
let eof = state.readers == 0;
|
||||
ProcessChunk {
|
||||
stdout: take_text(&mut state.stdout, max, eof),
|
||||
stderr: take_text(&mut state.stderr, max, eof),
|
||||
status: state.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes complete characters within the byte budget, retaining partial UTF-8
|
||||
/// until the reader gets the rest (or EOF). Even a tiny budget makes progress.
|
||||
fn take_text(buf: &mut Vec<u8>, max: usize, eof: bool) -> String {
|
||||
let limit = max.clamp(1, MAX_PENDING);
|
||||
let mut out = String::new();
|
||||
let mut consumed = 0;
|
||||
while consumed < buf.len() && out.len() < limit {
|
||||
let bytes = &buf[consumed..];
|
||||
let (valid, invalid) = match std::str::from_utf8(bytes) {
|
||||
Ok(text) => (text.len(), None),
|
||||
Err(e) => (e.valid_up_to(), Some(e.error_len())),
|
||||
};
|
||||
let text = std::str::from_utf8(&bytes[..valid]).expect("valid prefix");
|
||||
let mut end = text.len().min(limit - out.len());
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
if end == 0 && out.is_empty() && !text.is_empty() {
|
||||
end = text.chars().next().unwrap().len_utf8();
|
||||
}
|
||||
out.push_str(&text[..end]);
|
||||
consumed += end;
|
||||
if end < valid {
|
||||
break;
|
||||
}
|
||||
match invalid {
|
||||
Some(len) if len.is_some() || eof => {
|
||||
if !out.is_empty() && out.len() + '\u{fffd}'.len_utf8() > limit {
|
||||
break;
|
||||
}
|
||||
out.push('\u{fffd}');
|
||||
consumed += len.unwrap_or(bytes.len() - valid);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
buf.drain(..consumed);
|
||||
out
|
||||
}
|
||||
|
||||
/// Ends descendants before reaping an exited leader, while its process id is
|
||||
/// still reserved. Otherwise inherited pipes can keep a completed call waiting.
|
||||
fn try_wait_process(child: &mut Child) -> std::io::Result<Option<std::process::ExitStatus>> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::process::{Pid, WaitId, WaitIdOptions, waitid};
|
||||
let status = waitid(
|
||||
WaitId::Pid(Pid::from_child(child)),
|
||||
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
|
||||
)?;
|
||||
if status.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
ide_core::process::kill_tree(child);
|
||||
child.try_wait()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let status = child.try_wait()?;
|
||||
if status.is_some() {
|
||||
ide_core::process::kill_tree(child);
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
}
|
||||
|
||||
fn env_name_eq(a: &std::ffi::OsStr, b: &std::ffi::OsStr) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Use the platform's Command key comparison, including non-ASCII names.
|
||||
let mut probe = StdCommand::new("unused");
|
||||
probe.env(a, "").env(b, "");
|
||||
probe.get_envs().count() == 1
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
a == b
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates against the environment after `prepare`, then freezes that snapshot
|
||||
/// so only new keys are added, including when the host environment changes later.
|
||||
fn append_process_env(command: &mut StdCommand, env: &[(String, String)]) -> Result<(), HostError> {
|
||||
if env.len() > 64 {
|
||||
return Err(HostError::Invalid("at most 64 environment variables".into()));
|
||||
}
|
||||
let mut inherited: Vec<_> = std::env::vars_os().collect();
|
||||
for (key, value) in command.get_envs() {
|
||||
inherited.retain(|(k, _)| !env_name_eq(k, key));
|
||||
if let Some(value) = value {
|
||||
inherited.push((key.to_owned(), value.to_owned()));
|
||||
}
|
||||
}
|
||||
for (key, value) in env {
|
||||
if key.is_empty() || key.len() > 256 || key.contains(['=', '\0']) {
|
||||
return Err(HostError::Invalid(format!("{key:?} is not an environment variable name")));
|
||||
}
|
||||
if value.len() > 4096 || value.contains('\0') {
|
||||
return Err(HostError::Invalid(format!("the value of {key:?} is too long or holds a NUL")));
|
||||
}
|
||||
if inherited.iter().any(|(k, _)| env_name_eq(k, std::ffi::OsStr::new(key))) {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"environment variable {key:?} already exists; only new variables may be added"
|
||||
)));
|
||||
}
|
||||
inherited.push((key.into(), value.into()));
|
||||
}
|
||||
command.env_clear().envs(inherited);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// State shared by the host and every plugin's [`PluginApi`].
|
||||
pub(super) struct Shared {
|
||||
pub sink: Arc<dyn PluginEventSink>,
|
||||
@@ -183,6 +413,10 @@ pub(super) struct Shared {
|
||||
pub data_dir: PathBuf,
|
||||
tasks: Mutex<HashMap<u64, TaskEntry>>,
|
||||
next_task: AtomicU64,
|
||||
/// (plugin, handle id) → the processes it started and has not released.
|
||||
processes: Mutex<HashMap<(String, u64), Arc<Proc>>>,
|
||||
next_process: AtomicU64,
|
||||
process_shutdowns: Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
prompts: Mutex<HashMap<u64, PromptEntry>>,
|
||||
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
|
||||
prompts_blocked: Mutex<BTreeSet<String>>,
|
||||
@@ -222,6 +456,9 @@ impl Shared {
|
||||
data_dir,
|
||||
tasks: Mutex::default(),
|
||||
next_task: AtomicU64::new(1),
|
||||
processes: Mutex::default(),
|
||||
next_process: AtomicU64::new(1),
|
||||
process_shutdowns: Mutex::default(),
|
||||
prompts: Mutex::default(),
|
||||
prompts_blocked: Mutex::default(),
|
||||
next_prompt: AtomicU64::new(1),
|
||||
@@ -286,11 +523,41 @@ impl Shared {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drops the context keys scoped to window `client` (it closed), without events, and
|
||||
/// refuses further writes for it (late commands) until [`Self::reopen_client`].
|
||||
/// Cancels prompts and drops context keys scoped to window `client` (it closed).
|
||||
/// Refuses further writes for it (late commands) until [`Self::reopen_client`].
|
||||
pub fn forget_client(&self, client: &str) {
|
||||
self.closed_clients.lock().insert(client.to_owned());
|
||||
let mut closed = self.closed_clients.lock();
|
||||
closed.insert(client.to_owned());
|
||||
self.prompts.lock().retain(|_, entry| entry.client != client);
|
||||
drop(closed);
|
||||
self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client));
|
||||
let mine: Vec<_> = {
|
||||
let mut processes = self.processes.lock();
|
||||
let keys: Vec<_> = processes
|
||||
.iter()
|
||||
.filter(|(_, p)| p.client.as_deref() == Some(client))
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
keys.iter().filter_map(|key| processes.remove(key)).collect()
|
||||
};
|
||||
if !mine.is_empty() {
|
||||
// Window destruction runs on the UI thread; tree shutdown may wait.
|
||||
let worker = std::thread::spawn(move || {
|
||||
for proc in mine {
|
||||
proc.kill();
|
||||
}
|
||||
});
|
||||
let mut workers = self.process_shutdowns.lock();
|
||||
workers.retain(|worker| !worker.is_finished());
|
||||
workers.push(worker);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish_process_shutdowns(&self) {
|
||||
let workers = std::mem::take(&mut *self.process_shutdowns.lock());
|
||||
for worker in workers {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// A window labelled `client` is being created (labels are reused after a close).
|
||||
@@ -298,9 +565,14 @@ impl Shared {
|
||||
self.closed_clients.lock().remove(client);
|
||||
}
|
||||
|
||||
pub fn client_closed(&self, client: &str) -> bool {
|
||||
self.closed_clients.lock().contains(client)
|
||||
}
|
||||
|
||||
/// Withdraws a plugin's run-time state (it stopped or failed). Its
|
||||
/// subscriptions go only with `subscriptions`, as they survive restarts.
|
||||
pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) {
|
||||
self.kill_processes(plugin);
|
||||
let removed: Vec<String> = {
|
||||
let mut status = self.status.lock();
|
||||
let ids: Vec<String> = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect();
|
||||
@@ -341,6 +613,19 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends every process `plugin` started: its handles go with its instance
|
||||
/// (it stopped, was disabled, or was reclaimed while idle).
|
||||
pub fn kill_processes(&self, plugin: &str) {
|
||||
let mine: Vec<Arc<Proc>> = {
|
||||
let mut processes = self.processes.lock();
|
||||
let keys: Vec<(String, u64)> = processes.keys().filter(|(p, _)| p == plugin).cloned().collect();
|
||||
keys.iter().filter_map(|k| processes.remove(k)).collect()
|
||||
};
|
||||
for proc in mine {
|
||||
proc.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// The user cancelled a task in the UI.
|
||||
pub fn cancel_task(&self, id: u64) -> bool {
|
||||
let mut tasks = self.tasks.lock();
|
||||
@@ -643,14 +928,18 @@ impl PluginApi {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (answer, answers) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
if closed.contains(client) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
|
||||
self.shared.prompts.lock().insert(
|
||||
id,
|
||||
PromptEntry { plugin: self.id().to_owned(), client: client.to_owned(), answer: Waiter::Commit(answer) },
|
||||
);
|
||||
}
|
||||
let request_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
@@ -676,6 +965,23 @@ impl PluginApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Where `cmd` runs: the project root it names, else the plugin's data
|
||||
/// directory (created as needed).
|
||||
fn spawn_dir(&self, cwd: Option<&Path>) -> PathBuf {
|
||||
let dir = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// The process handle `id`, if this plugin started it and it is still open.
|
||||
fn process(&self, id: u64) -> Result<Arc<Proc>, HostError> {
|
||||
self.shared
|
||||
.processes
|
||||
.lock()
|
||||
.get(&(self.id().to_owned(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound(format!("no process {id}: it was closed, or belongs to another plugin")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The changes `selection` commits, diffed repository by repository.
|
||||
@@ -769,14 +1075,18 @@ impl HostApi for PluginApi {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (answer, answers) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
if closed.contains(client) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
|
||||
self.shared.prompts.lock().insert(
|
||||
id,
|
||||
PromptEntry { plugin: self.id().to_owned(), client: client.to_owned(), answer: Waiter::Prompt(answer) },
|
||||
);
|
||||
}
|
||||
let prompt_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
@@ -1002,6 +1312,26 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn symbols(
|
||||
&self,
|
||||
root: &Path,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
cancel: &CancellationToken,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
|
||||
if query.len() > 256 {
|
||||
return Err(HostError::Invalid("symbol query exceeds 256 UTF-8 bytes".into()));
|
||||
}
|
||||
self.shared.workspace().ok_or_else(|| HostError::NotFound("no project index is available".into()))?.symbols(
|
||||
root,
|
||||
query,
|
||||
limit.min(1000),
|
||||
cancel,
|
||||
deadline,
|
||||
)
|
||||
}
|
||||
|
||||
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
|
||||
let matcher = globset::GlobBuilder::new(glob)
|
||||
.literal_separator(true)
|
||||
@@ -1112,22 +1442,24 @@ impl HostApi for PluginApi {
|
||||
|
||||
fn run_process(
|
||||
&self,
|
||||
program: &str,
|
||||
args: Vec<String>,
|
||||
cmd: ProcessCommand,
|
||||
cwd: Option<&Path>,
|
||||
stdin: Option<String>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<ProcessOutput, HostError> {
|
||||
let binary = self.approved_program(program)?;
|
||||
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
|
||||
let _ = std::fs::create_dir_all(&cwd);
|
||||
let mut child = std::process::Command::new(&binary)
|
||||
.args(&args)
|
||||
let binary = self.approved_program(&cmd.program)?;
|
||||
let cwd = self.spawn_dir(cwd);
|
||||
let mut command = StdCommand::new(&binary);
|
||||
ide_core::process::prepare(&mut command);
|
||||
append_process_env(&mut command, &cmd.env)?;
|
||||
ide_core::process::own_group(&mut command);
|
||||
let mut child = command
|
||||
.args(&cmd.args)
|
||||
.current_dir(&cwd)
|
||||
.stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() })
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
|
||||
if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) {
|
||||
@@ -1136,30 +1468,36 @@ impl HostApi for PluginApi {
|
||||
let _ = pipe.write_all(input.as_bytes());
|
||||
});
|
||||
}
|
||||
let read = |pipe: Option<Box<dyn std::io::Read + Send>>| {
|
||||
let read = |pipe: Option<Box<dyn Read + Send>>| {
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = pipe {
|
||||
let _ = p.take(MAX_OUTPUT as u64).read_to_end(&mut out);
|
||||
let mut p = p;
|
||||
let _ = p.by_ref().take(MAX_OUTPUT as u64).read_to_end(&mut out);
|
||||
let _ = std::io::copy(&mut p, &mut std::io::sink());
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
let mut text = String::from_utf8_lossy(&out).into_owned();
|
||||
let mut end = text.len().min(MAX_OUTPUT);
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.truncate(end);
|
||||
text
|
||||
})
|
||||
};
|
||||
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
|
||||
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
|
||||
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let deadline = Instant::now() + timeout;
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().map_err(|e| HostError::Failed(e.to_string()))? {
|
||||
if let Some(status) = try_wait_process(&mut child).map_err(|e| HostError::Failed(e.to_string()))? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() || Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
ide_core::process::kill_tree(&mut child);
|
||||
return Err(if cancel.is_cancelled() {
|
||||
HostError::Cancelled
|
||||
} else {
|
||||
HostError::Failed(format!("{program} timed out after {timeout:?}"))
|
||||
HostError::Failed(format!("{} timed out after {timeout:?}", cmd.program))
|
||||
});
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
@@ -1190,6 +1528,169 @@ impl HostApi for PluginApi {
|
||||
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result<u64, HostError> {
|
||||
let binary = self.approved_program(&cmd.program)?;
|
||||
let cwd = self.spawn_dir(cwd);
|
||||
if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
{
|
||||
let processes = self.shared.processes.lock();
|
||||
let running = processes.keys().filter(|(plugin, _)| plugin == self.id()).count();
|
||||
if running >= MAX_PROCESSES {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"at most {MAX_PROCESSES} processes may run at once: release one first"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut command = StdCommand::new(&binary);
|
||||
ide_core::process::prepare(&mut command);
|
||||
append_process_env(&mut command, &cmd.env)?;
|
||||
ide_core::process::own_group(&mut command);
|
||||
let mut child = command
|
||||
.args(&cmd.args)
|
||||
.current_dir(&cwd)
|
||||
// Always piped: `write` may come later, and a plugin that never
|
||||
// writes closes it (or loses it when it releases the handle).
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
|
||||
let (stdout, stderr) = (child.stdout.take(), child.stderr.take());
|
||||
let proc = Arc::new(Proc {
|
||||
client: client.map(str::to_owned),
|
||||
state: Mutex::new(ProcState {
|
||||
stdin: child.stdin.take(),
|
||||
child,
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
status: None,
|
||||
readers: 0,
|
||||
}),
|
||||
});
|
||||
if let Some(pipe) = stdout {
|
||||
proc.read(pipe, Stream::Out);
|
||||
}
|
||||
if let Some(pipe) = stderr {
|
||||
proc.read(pipe, Stream::Err);
|
||||
}
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
if client.is_some_and(|c| closed.contains(c)) {
|
||||
drop(closed);
|
||||
proc.kill();
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let id = self.shared.next_process.fetch_add(1, Ordering::Relaxed);
|
||||
self.shared.processes.lock().insert((self.id().to_owned(), id), proc);
|
||||
drop(closed);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn poll_process(&self, id: u64, max: usize) -> Result<ProcessChunk, HostError> {
|
||||
let proc = self.process(id)?;
|
||||
proc.reap()?;
|
||||
Ok(proc.chunk(max))
|
||||
}
|
||||
|
||||
fn write_process(
|
||||
&self,
|
||||
id: u64,
|
||||
text: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let proc = self.process(id)?;
|
||||
proc.reap()?;
|
||||
let mut pipe = {
|
||||
let mut state = proc.state.lock();
|
||||
if state.status.is_some() {
|
||||
return Err(HostError::Failed("the process already ended".into()));
|
||||
}
|
||||
state.stdin.take().ok_or_else(|| HostError::Failed("its stdin is closed".into()))?
|
||||
};
|
||||
let input = text.to_owned();
|
||||
let writer = proc.clone();
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let result = pipe.write_all(input.as_bytes()).and_then(|()| pipe.flush());
|
||||
let mut state = writer.state.lock();
|
||||
if state.status.is_none() {
|
||||
state.stdin = Some(pipe);
|
||||
}
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if cancel.is_cancelled() || Instant::now() >= deadline {
|
||||
proc.kill();
|
||||
return Err(if cancel.is_cancelled() {
|
||||
HostError::Cancelled
|
||||
} else {
|
||||
HostError::Failed("stdin write timed out".into())
|
||||
});
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(result) => return result.map_err(|e| HostError::Failed(format!("stdin: {e}"))),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(_) => return Err(HostError::Failed("stdin writer stopped".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_process_stdin(&self, id: u64) -> Result<(), HostError> {
|
||||
let proc = self.process(id)?;
|
||||
proc.reap()?;
|
||||
let mut state = proc.state.lock();
|
||||
if state.status.is_some() {
|
||||
return Err(HostError::Failed("the process already ended".into()));
|
||||
}
|
||||
state.stdin = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release_process(&self, id: u64) {
|
||||
let proc = self.shared.processes.lock().remove(&(self.id().to_owned(), id));
|
||||
if let Some(proc) = proc {
|
||||
proc.kill();
|
||||
}
|
||||
}
|
||||
|
||||
fn kill_process(&self, id: u64) -> Result<(), HostError> {
|
||||
self.process(id)?.kill();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_process(
|
||||
&self,
|
||||
id: u64,
|
||||
max: usize,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<ProcessChunk, HostError> {
|
||||
let proc = self.process(id)?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
proc.reap()?;
|
||||
let (ended, readers) = {
|
||||
let state = proc.state.lock();
|
||||
(state.status.is_some(), state.readers)
|
||||
};
|
||||
// Its output is only complete once its pipes reached the end too.
|
||||
if ended && readers == 0 {
|
||||
break;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Ok(proc.chunk(max))
|
||||
}
|
||||
|
||||
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64 {
|
||||
let id = self.shared.next_task.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -1406,6 +1907,57 @@ impl PluginWorkspace for HostWorkspace {
|
||||
project.index.files_matching(root, matches, limit)
|
||||
}
|
||||
|
||||
fn symbols(
|
||||
&self,
|
||||
root: &Path,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
cancel: &CancellationToken,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
|
||||
let project = HostWorkspace::projects(self)
|
||||
.into_iter()
|
||||
.find(|p| p.root == root)
|
||||
.ok_or_else(|| HostError::NotFound("the project is no longer open".into()))?;
|
||||
let stopped = || cancel.is_cancelled() || Instant::now() >= deadline;
|
||||
let interrupted = || {
|
||||
if cancel.is_cancelled() {
|
||||
HostError::Cancelled
|
||||
} else {
|
||||
HostError::Failed("symbol search timed out".into())
|
||||
}
|
||||
};
|
||||
let symbols = project
|
||||
.index
|
||||
.find_symbols_until(
|
||||
query,
|
||||
ide_index::SymbolFilter::All,
|
||||
limit.min(1000),
|
||||
&|path| path.starts_with(root),
|
||||
&stopped,
|
||||
)
|
||||
.ok_or_else(interrupted)?;
|
||||
let mut out = Vec::new();
|
||||
for s in symbols {
|
||||
if stopped() {
|
||||
return Err(interrupted());
|
||||
}
|
||||
out.push(ide_plugin::SymbolInfo {
|
||||
name: s.name,
|
||||
kind: serde_json::to_value(s.kind)
|
||||
.ok()
|
||||
.and_then(|k| k.as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "unknown".into()),
|
||||
container: s.container,
|
||||
location: ide_plugin::Location { path: s.path.0, range: s.range },
|
||||
});
|
||||
}
|
||||
if stopped() {
|
||||
return Err(interrupted());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
let (project, doc) = self.open_document(path)?;
|
||||
let snap = project.documents.snapshot(doc).ok()?;
|
||||
@@ -1631,6 +2183,182 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_environment_only_adds_variables() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let binary = ide_core::process::find_executable(
|
||||
"git",
|
||||
&ide_core::process::user_path().or_else(|| std::env::var_os("PATH")).unwrap(),
|
||||
)
|
||||
.expect("git on PATH");
|
||||
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
|
||||
pins.write().insert(("acme.lint".into(), "process:git".into()), ProgramPin::approve(&binary).unwrap());
|
||||
let api = api(tmp.path(), pins);
|
||||
let inherited = std::env::vars_os()
|
||||
.find(|(key, _)| key != "PATH" && key.to_str().is_some_and(|key| !key.is_empty() && !key.contains('=')))
|
||||
.unwrap()
|
||||
.0
|
||||
.into_string()
|
||||
.unwrap();
|
||||
for key in ["PATH".to_owned(), inherited] {
|
||||
let cmd = ProcessCommand {
|
||||
program: "git".into(),
|
||||
env: vec![(key.clone(), "replacement".into())],
|
||||
..Default::default()
|
||||
};
|
||||
let err = api
|
||||
.run_process(cmd.clone(), None, None, &CancellationToken::new(), Duration::from_secs(5))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, HostError::Invalid(ref message) if message.contains(&key)), "{err}");
|
||||
let err = api.start_process(cmd, None, None).unwrap_err();
|
||||
assert!(matches!(err, HostError::Invalid(ref message) if message.contains(&key)), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_environment_uses_prepared_values_and_windows_key_rules() {
|
||||
let mut cmd = StdCommand::new("unused");
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
cmd.env("OPENIDEA_PREPARED_ENV", "login-shell");
|
||||
let err = append_process_env(&mut cmd, &[("OPENIDEA_PREPARED_ENV".into(), "override".into())]).unwrap_err();
|
||||
assert!(err.to_string().contains("OPENIDEA_PREPARED_ENV"));
|
||||
for name in ["", "BAD=KEY", "BAD\0KEY"] {
|
||||
assert!(append_process_env(&mut cmd, &[(name.into(), "value".into())]).is_err());
|
||||
}
|
||||
for value in ["bad\0value".to_owned(), "x".repeat(4097)] {
|
||||
assert!(append_process_env(&mut cmd, &[("OPENIDEA_NEW_ENV".into(), value)]).is_err());
|
||||
}
|
||||
assert!(
|
||||
append_process_env(
|
||||
&mut cmd,
|
||||
&[("OPENIDEA_NEW_ENV".into(), "a".into()), ("OPENIDEA_NEW_ENV".into(), "b".into())]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(env_name_eq("PATH".as_ref(), "Path".as_ref()), cfg!(windows));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(append_process_env(&mut cmd, &[("pAtH".into(), "override".into())]).is_err());
|
||||
assert!(env_name_eq("Ä_KEY".as_ref(), "ä_key".as_ref()));
|
||||
}
|
||||
cmd.env_remove("OPENIDEA_REMOVED_ENV");
|
||||
append_process_env(&mut cmd, &[("OPENIDEA_REMOVED_ENV".into(), "added".into())]).unwrap();
|
||||
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "OPENIDEA_REMOVED_ENV").unwrap().1, Some("added".as_ref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_output_preserves_partial_utf8_and_bounds_replacement_text() {
|
||||
let mut bytes = vec![0xe4, 0xb8];
|
||||
assert_eq!(take_text(&mut bytes, 1, false), "");
|
||||
bytes.extend_from_slice(&[0xad, b'x']);
|
||||
assert_eq!(take_text(&mut bytes, 1, false), "中");
|
||||
assert_eq!(take_text(&mut bytes, 0, false), "x");
|
||||
let mut bytes = vec![0xff; MAX_PENDING];
|
||||
let text = take_text(&mut bytes, MAX_PENDING, true);
|
||||
assert!(text.len() <= MAX_PENDING);
|
||||
assert!(!bytes.is_empty());
|
||||
assert_eq!(take_text(&mut vec![0xe4, 0xb8], 8, true), "�");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn shell_api(dir: &Path) -> PluginApi {
|
||||
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
|
||||
pins.write()
|
||||
.insert(("acme.lint".into(), "process:sh".into()), ProgramPin::approve(Path::new("/bin/sh")).unwrap());
|
||||
api(dir, pins)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn shell(script: &str) -> ProcessCommand {
|
||||
ProcessCommand { program: "sh".into(), args: vec!["-c".into(), script.into()], env: Vec::new() }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_handles_are_bounded_scoped_and_released() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = shell_api(tmp.path());
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..MAX_PROCESSES {
|
||||
ids.push(api.start_process(shell("exec cat"), None, Some("main")).unwrap());
|
||||
}
|
||||
assert!(matches!(api.start_process(shell("exec cat"), None, None), Err(HostError::Invalid(_))));
|
||||
let proc = api.process(ids[0]).unwrap();
|
||||
api.release_process(ids[0]);
|
||||
assert!(proc.state.lock().child.try_wait().unwrap().is_some());
|
||||
assert!(api.process(ids[0]).is_err());
|
||||
let other = api.start_process(shell("exec cat"), None, Some("other")).unwrap();
|
||||
api.shared.forget_client("main");
|
||||
assert!(api.process(ids[1]).is_err());
|
||||
assert!(api.process(other).is_ok());
|
||||
assert!(matches!(api.start_process(shell("exec cat"), None, Some("main")), Err(HostError::Cancelled)));
|
||||
api.shared.kill_processes(api.id());
|
||||
assert!(api.shared.processes.lock().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_stdin_timeout_and_stop_do_not_deadlock() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = Arc::new(shell_api(tmp.path()));
|
||||
for stop in [false, true] {
|
||||
let id = api.start_process(shell("exec sleep 60"), None, Some("main")).unwrap();
|
||||
let proc = api.process(id).unwrap();
|
||||
let writer = api.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
writer.write_process(id, &"x".repeat(1 << 20), &CancellationToken::new(), Duration::from_millis(150))
|
||||
});
|
||||
if stop {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
api.shared.kill_processes(api.id());
|
||||
}
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
assert!(proc.state.lock().child.try_wait().unwrap().is_some());
|
||||
api.release_process(id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_parent_exit_closes_descendant_pipes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = shell_api(tmp.path());
|
||||
let token = CancellationToken::new();
|
||||
let before = Instant::now();
|
||||
let out =
|
||||
api.run_process(shell("sleep 60 & echo done"), None, None, &token, Duration::from_millis(300)).unwrap();
|
||||
assert_eq!(out.status, Some(0));
|
||||
assert_eq!(out.stdout.trim(), "done");
|
||||
assert!(before.elapsed() < Duration::from_secs(5));
|
||||
let id = api.start_process(shell("sleep 60 & echo done"), None, None).unwrap();
|
||||
let out = api.wait_process(id, MAX_PENDING, Duration::from_millis(300), &token).unwrap();
|
||||
assert_eq!(out.status, Some(0));
|
||||
assert_eq!(out.stdout.trim(), "done");
|
||||
assert_eq!(api.process(id).unwrap().state.lock().readers, 0);
|
||||
api.release_process(id);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_output_is_drained_and_retention_is_bounded() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = shell_api(tmp.path());
|
||||
let token = CancellationToken::new();
|
||||
let cmd = shell("head -c 5242880 /dev/zero; head -c 5242880 /dev/zero >&2");
|
||||
let out = api.run_process(cmd.clone(), None, None, &token, Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(out.status, Some(0));
|
||||
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_OUTPUT, MAX_OUTPUT));
|
||||
let id = api.start_process(cmd, None, None).unwrap();
|
||||
let out = api.wait_process(id, MAX_PENDING, Duration::from_secs(5), &token).unwrap();
|
||||
assert_eq!(out.status, Some(0));
|
||||
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_PENDING, MAX_PENDING));
|
||||
let state = api.process(id).unwrap();
|
||||
let state = state.state.lock();
|
||||
assert!(state.stdout.is_empty() && state.stderr.is_empty());
|
||||
drop(state);
|
||||
api.release_process(id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_run_only_as_approved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Versioned, project-scoped diagnostics for unsaved editor buffers.
|
||||
use super::{PluginHost, ProviderKind, editor_plugin_host};
|
||||
use ide_core::{CancellationToken, Diagnostic, DocId, IdeEvent, SharedSink};
|
||||
use ide_plugin::ProviderDocument;
|
||||
use ide_text::AppliedChange;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
struct Document {
|
||||
snapshot: ProviderDocument,
|
||||
cancel: CancellationToken,
|
||||
sources: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
closed: bool,
|
||||
documents: HashMap<DocId, Document>,
|
||||
generations: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
/// One incarnation of one project's documents, shared by all its editors.
|
||||
pub(crate) struct Diagnostics {
|
||||
state: Arc<Mutex<State>>,
|
||||
sink: SharedSink,
|
||||
runtime: tokio::runtime::Handle,
|
||||
documents: Weak<Documents>,
|
||||
root: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Diagnostics {
|
||||
pub fn new(
|
||||
sink: SharedSink,
|
||||
runtime: tokio::runtime::Handle,
|
||||
documents: &Arc<Documents>,
|
||||
root: &Path,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
state: Arc::default(),
|
||||
sink,
|
||||
runtime,
|
||||
documents: Arc::downgrade(documents),
|
||||
root: root.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&self, path: &Path, plugin: &str, items: Vec<Diagnostic>) {
|
||||
self.sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.to_path_buf().into(),
|
||||
source: format!("plugin-provider:{plugin}"),
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_document(&self, doc: &Document) {
|
||||
doc.cancel.cancel();
|
||||
for plugin in &doc.sources {
|
||||
self.emit(&doc.snapshot.path, plugin, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let mut state = self.state.lock();
|
||||
state.closed = true;
|
||||
for (_, doc) in state.documents.drain() {
|
||||
self.clear_document(&doc);
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule(&self, id: DocId, state: &mut State) {
|
||||
let Some(doc) = state.documents.get_mut(&id) else { return };
|
||||
doc.cancel.cancel();
|
||||
doc.cancel = CancellationToken::new();
|
||||
let Some(host) = editor_plugin_host() else { return };
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
|
||||
return;
|
||||
}
|
||||
// Register only once per project incarnation; no project is retained by the host.
|
||||
let weak = Arc::downgrade(&self.state);
|
||||
{
|
||||
let mut projects = host.diagnostic_projects.lock();
|
||||
projects.retain(|(state, _)| state.strong_count() > 0);
|
||||
if !projects.iter().any(|(state, _)| state.ptr_eq(&weak)) {
|
||||
projects.push((weak.clone(), self.sink.clone()));
|
||||
}
|
||||
}
|
||||
let root = self.root.clone();
|
||||
let (mut snapshot, cancel, sink, documents) =
|
||||
(doc.snapshot.clone(), doc.cancel.clone(), self.sink.clone(), self.documents.clone());
|
||||
let lifecycle_generations = state.generations.clone();
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(DEBOUNCE).await;
|
||||
if cancel.is_cancelled() || weak.strong_count() == 0 {
|
||||
return;
|
||||
}
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
let Some(documents) = documents.upgrade() else { return };
|
||||
let Ok(current) = documents.snapshot(id) else { return };
|
||||
if u64::from(current.version) != snapshot.version
|
||||
|| current.path.0 != snapshot.path
|
||||
|| current.language.as_str() != snapshot.language
|
||||
|| cancel.is_cancelled()
|
||||
{
|
||||
return;
|
||||
}
|
||||
snapshot.text = current.text;
|
||||
drop(documents);
|
||||
|
||||
let generations = host.providers.diagnostic_generations(&snapshot.language);
|
||||
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
|
||||
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
let mut state = state.lock();
|
||||
if state.closed || cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let matching: HashSet<_> = host
|
||||
.diagnostic_plugins(Some(&root), &snapshot.language)
|
||||
.into_iter()
|
||||
.map(|p| p.id().to_owned())
|
||||
.filter(|p| lifecycle_generations.get(p) == state.generations.get(p))
|
||||
.collect();
|
||||
let latest = host.providers.diagnostic_generations(&snapshot.language);
|
||||
let Some(doc) = state.documents.get_mut(&id) else { return };
|
||||
if doc.snapshot.version != snapshot.version || doc.snapshot.path != snapshot.path {
|
||||
return;
|
||||
}
|
||||
let mut current = HashSet::new();
|
||||
for (plugin, items) in results {
|
||||
if !matching.contains(&plugin)
|
||||
|| !generations.get(&plugin).is_some_and(|seq| latest.get(&plugin) == Some(seq))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
current.insert(plugin.clone());
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{plugin}"),
|
||||
items,
|
||||
});
|
||||
}
|
||||
for plugin in doc.sources.difference(¤t) {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{plugin}"),
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
doc.sources = current;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentObserver for Diagnostics {
|
||||
fn opened(&self, info: &DocumentInfo) {
|
||||
let mut state = self.state.lock();
|
||||
if state.closed {
|
||||
return;
|
||||
}
|
||||
if let Some(old) = state.documents.remove(&info.id) {
|
||||
self.clear_document(&old);
|
||||
}
|
||||
if info.large || info.uri.is_some() {
|
||||
return;
|
||||
}
|
||||
state.documents.insert(
|
||||
info.id,
|
||||
Document {
|
||||
snapshot: ProviderDocument {
|
||||
path: info.path.0.clone(),
|
||||
language: info.language.to_string(),
|
||||
version: u64::from(info.version),
|
||||
text: String::new(),
|
||||
},
|
||||
cancel: CancellationToken::new(),
|
||||
sources: HashSet::new(),
|
||||
},
|
||||
);
|
||||
self.schedule(info.id, &mut state);
|
||||
}
|
||||
|
||||
fn changed(&self, id: DocId, path: &Path, version: u32, _: &[AppliedChange], _text: &dyn Fn() -> String) {
|
||||
let mut state = self.state.lock();
|
||||
if state.closed {
|
||||
return;
|
||||
}
|
||||
let Some(doc) = state.documents.get_mut(&id) else { return };
|
||||
if doc.snapshot.path != path {
|
||||
self.clear_document(doc);
|
||||
doc.sources.clear();
|
||||
}
|
||||
doc.snapshot.path = path.to_path_buf();
|
||||
doc.snapshot.version = u64::from(version);
|
||||
self.schedule(id, &mut state);
|
||||
}
|
||||
|
||||
fn closed(&self, id: DocId, _: &Path) {
|
||||
if let Some(doc) = self.state.lock().documents.remove(&id) {
|
||||
self.clear_document(&doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Diagnostics {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
|
||||
|
||||
impl PluginHost {
|
||||
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
|
||||
// Clone before taking a project's state lock (edits register in the other order).
|
||||
let projects = self.diagnostic_projects.lock().clone();
|
||||
for (state, sink) in projects {
|
||||
if let Some(state) = state.upgrade() {
|
||||
let mut state = state.lock();
|
||||
// A completed answer can still be waiting for another provider.
|
||||
*state.generations.entry(plugin.to_owned()).or_default() += 1;
|
||||
for doc in state.documents.values_mut() {
|
||||
if doc.sources.remove(plugin) {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{plugin}"),
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,6 +510,8 @@ fn check_value(key: &str, value: &str) -> Result<(), String> {
|
||||
if lower.contains("javascript:")
|
||||
|| lower.contains("expression(")
|
||||
|| lower.contains("@import")
|
||||
// These CSS image/URL functions accept bare strings, bypassing url().
|
||||
|| ["image(", "image-set(", "src("].iter().any(|function| lower.contains(function))
|
||||
|| value.contains('\\')
|
||||
{
|
||||
return Err("unsafe value".into());
|
||||
@@ -607,6 +609,10 @@ mod tests {
|
||||
r##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
|
||||
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
|
||||
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
|
||||
r##"<svg><rect style="background-image:image-set('https://evil/x.png' 1x)"/></svg>"##,
|
||||
r##"<svg><rect style="background-image:-webkit-image-set('https://evil/x.png' 1x)"/></svg>"##,
|
||||
r##"<svg><rect style="background-image:image('https://evil/x.png')"/></svg>"##,
|
||||
r##"<svg><rect style="fill:src('https://evil/x.svg')"/></svg>"##,
|
||||
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
|
||||
r##"<svg><![CDATA[x]]></svg>"##,
|
||||
r##"<svg><a href="#x"/></svg>"##,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
mod api;
|
||||
pub mod commit;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
mod hover;
|
||||
@@ -326,6 +327,8 @@ pub struct PluginHost {
|
||||
providers: Arc<providers::Providers>,
|
||||
navigation: Mutex<navigation::NavigationIndex>,
|
||||
endpoints: Mutex<endpoints::Endpoints>,
|
||||
web_ptys: Mutex<BTreeMap<String, Vec<web::WebPtySession>>>,
|
||||
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
|
||||
/// Validated UI declarations of installed packages (start-up reuses them).
|
||||
index: Arc<ContributionIndex>,
|
||||
ledger: Mutex<Ledger>,
|
||||
@@ -382,7 +385,8 @@ impl PluginHost {
|
||||
let ui = UiStore::new();
|
||||
let index = Arc::new(ContributionIndex::new(
|
||||
plugins_dir.join(".cache").join("contributions"),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
// Revalidate SVGs accepted before URL-valued CSS functions were rejected.
|
||||
concat!(env!("CARGO_PKG_VERSION"), ":svg-2"),
|
||||
));
|
||||
ui.set_index(Some(index.clone()));
|
||||
let languages = PluginLanguages::new();
|
||||
@@ -416,6 +420,8 @@ impl PluginHost {
|
||||
providers,
|
||||
navigation: Mutex::default(),
|
||||
endpoints: Mutex::default(),
|
||||
web_ptys: Mutex::default(),
|
||||
diagnostic_projects: Mutex::default(),
|
||||
index,
|
||||
plugins: RwLock::default(),
|
||||
state: Mutex::new(state),
|
||||
@@ -1096,8 +1102,7 @@ impl PluginHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// The Services pane of node `key` of the plugin's `location = "services"`
|
||||
/// view `view`.
|
||||
/// The detail tabs of item `key` of a Services or `detail = true` view.
|
||||
pub fn service_detail(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -1105,9 +1110,9 @@ impl PluginHost {
|
||||
key: &str,
|
||||
invocation: Invocation,
|
||||
) -> IdeResult<Vec<ide_plugin::ServiceTab>> {
|
||||
self.services_view(id, view)?;
|
||||
self.detail_view(id, view)?;
|
||||
let (view, key) = (view.to_owned(), key.to_owned());
|
||||
self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| {
|
||||
self.code_call(id, invocation, "rendering the item's detail pane", move |rt, inv| {
|
||||
rt.service_detail(&view, &key, inv)
|
||||
})
|
||||
}
|
||||
@@ -1163,16 +1168,16 @@ impl PluginHost {
|
||||
if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) }
|
||||
}
|
||||
|
||||
fn services_view(&self, id: &str, view: &str) -> IdeResult<()> {
|
||||
let services = self
|
||||
.ui
|
||||
.items(ui_points::VIEWS, Some(id))
|
||||
.iter()
|
||||
.any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION));
|
||||
if services {
|
||||
fn detail_view(&self, id: &str, view: &str) -> IdeResult<()> {
|
||||
let detail = self.ui.items(ui_points::VIEWS, Some(id)).iter().any(|i| {
|
||||
i.key == view
|
||||
&& (i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION)
|
||||
|| i.decl["detail"].as_bool() == Some(true))
|
||||
});
|
||||
if detail {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}")))
|
||||
Err(IdeError::not_found(format!("plugin {id:?} declares no detail view {view:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,11 +1370,12 @@ impl PluginHost {
|
||||
self.shared.reopen_client(label);
|
||||
}
|
||||
|
||||
/// Window `label` closed: drops its context keys and queued events, and ignores
|
||||
/// context writes of commands still running for it.
|
||||
/// Window `label` closed: cancels its prompts, drops its context keys and queued
|
||||
/// events, and ignores context writes of commands still running for it.
|
||||
pub fn forget_client(&self, label: &str) {
|
||||
self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label));
|
||||
self.shared.forget_client(label);
|
||||
self.forget_web_ptys(label);
|
||||
}
|
||||
|
||||
/// The user cancelled a plugin task (`task_id` from `TaskStarted`).
|
||||
@@ -1762,6 +1768,7 @@ impl PluginHost {
|
||||
let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect();
|
||||
for id in self.plugins.read().keys() {
|
||||
self.shared.block_prompts(id);
|
||||
self.stop_web_ptys(id);
|
||||
}
|
||||
for a in &actors {
|
||||
a.stop(false);
|
||||
@@ -1769,6 +1776,7 @@ impl PluginHost {
|
||||
for a in actors {
|
||||
a.stop(true);
|
||||
}
|
||||
self.shared.finish_process_shutdowns();
|
||||
}
|
||||
|
||||
/// Periodic upkeep (every few seconds): reports crashes nobody waited
|
||||
@@ -1937,6 +1945,7 @@ impl PluginHost {
|
||||
/// whose activation event already fired.
|
||||
fn start(&self, plugin: &Arc<LoadedPlugin>) {
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
self.clear_provider_diagnostics(plugin.id());
|
||||
if plugin.source == PluginSource::Installed
|
||||
&& let Some(reason) = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir())
|
||||
{
|
||||
@@ -2119,6 +2128,7 @@ impl PluginHost {
|
||||
/// A trap dropped the plugin's instance: restart it on next use after a
|
||||
/// backoff, or stop it for the session after too many crashes in a row.
|
||||
fn crashed(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
|
||||
self.clear_provider_diagnostics(plugin.id());
|
||||
let failures = {
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.failures += 1;
|
||||
@@ -2165,6 +2175,8 @@ impl PluginHost {
|
||||
*runtime = None;
|
||||
}
|
||||
actor.stop(false);
|
||||
// Its handles go with its instance; an idle reclaim is one.
|
||||
self.shared.kill_processes(plugin.id());
|
||||
let mut meta = plugin.meta.lock();
|
||||
if meta.status == PluginStatus::Active {
|
||||
meta.status = PluginStatus::Inactive;
|
||||
@@ -2175,6 +2187,7 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn stop(&self, plugin: &LoadedPlugin) {
|
||||
self.stop_web_ptys(plugin.id());
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
self.clear_plugin_endpoints(plugin.id());
|
||||
// A call waiting on the user would keep the join below waiting.
|
||||
@@ -2183,6 +2196,7 @@ impl PluginHost {
|
||||
actor.stop(true);
|
||||
}
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
self.clear_provider_diagnostics(plugin.id());
|
||||
self.shared.clear_plugin(plugin.id(), true);
|
||||
self.queues.lock().retain(|(id, _), _| id != plugin.id());
|
||||
}
|
||||
@@ -2192,9 +2206,12 @@ impl PluginHost {
|
||||
self.clear_plugin_endpoints(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "{error}");
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
self.clear_provider_diagnostics(plugin.id());
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.status = PluginStatus::Failed;
|
||||
meta.error = Some(error);
|
||||
drop(meta);
|
||||
self.stop_web_ptys(plugin.id());
|
||||
}
|
||||
|
||||
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
|
||||
@@ -2558,6 +2575,70 @@ impl ide_language::ExternalSyntax for PluginSyntax {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_panics_stop_actor_and_reply_on_every_call_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host =
|
||||
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics");
|
||||
host.install_from_dir(&fixture, &Consent::all()).unwrap();
|
||||
let plugin = host.get("test.diagnostics").unwrap();
|
||||
for mode in 0..5 {
|
||||
let (actor, _) = host.ensure_active(&plugin, None).unwrap();
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let next_actor = actor.clone();
|
||||
let (queued_tx, queued_rx) = std::sync::mpsc::channel();
|
||||
let panic = move |_: &mut Runtime| -> Result<(), CallError> {
|
||||
let pending = next_actor.submit(None, |_| Ok("must not run")).unwrap();
|
||||
queued_tx.send(pending).unwrap();
|
||||
panic!("host call panic")
|
||||
};
|
||||
let result = match mode {
|
||||
0 => actor.call(None, panic),
|
||||
1 => actor.call_cancellable(&cancel, Duration::ZERO, panic),
|
||||
2 => actor.call_edit_cancellable(&cancel, Duration::ZERO, Duration::from_secs(5), panic),
|
||||
_ => {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
if mode == 3 {
|
||||
actor.post(panic, move |result| {
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
} else {
|
||||
actor.post(
|
||||
|_| Ok(()),
|
||||
move |result| {
|
||||
let _ = tx.send(result);
|
||||
panic!("completion callback panic");
|
||||
},
|
||||
);
|
||||
}
|
||||
rx.recv_timeout(Duration::from_secs(5)).expect("actor must reply")
|
||||
}
|
||||
};
|
||||
if mode < 4 {
|
||||
let queued = queued_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(matches!(queued.wait(), Err(ActorError::Stopped)), "poisoned runtime must not run queued work");
|
||||
}
|
||||
actor.stop(true);
|
||||
if mode < 4 {
|
||||
assert!(
|
||||
matches!(result, Err(ActorError::Call(CallError::Trap(ref message))) if message.contains("host call panic")),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
assert!(!actor.is_alive());
|
||||
assert!(!actor.is_busy());
|
||||
assert!(matches!(actor.call(None, |_| Ok(())), Err(ActorError::Stopped)));
|
||||
let crash = actor.take_crash().expect("supervisor must see the panic");
|
||||
assert!(crash.contains(if mode == 4 { "completion callback panic" } else { "host call panic" }));
|
||||
assert!(actor.take_crash().is_none());
|
||||
cancel.cancel();
|
||||
// Re-enable to reset the supervisor before testing the next call path.
|
||||
host.set_enabled("test.diagnostics", false).unwrap();
|
||||
host.set_enabled("test.diagnostics", true).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globs() {
|
||||
assert!(glob_match("**/*Mapper.xml", "src/main/UserMapper.xml"));
|
||||
|
||||
@@ -67,6 +67,7 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::MARKER_ICONS, ui.clone(), p::marker_icon)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -40,6 +41,7 @@ pub enum ProviderKind {
|
||||
SemanticTokens,
|
||||
Hover,
|
||||
InlayHints,
|
||||
Diagnostics,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -73,13 +75,26 @@ impl Providers {
|
||||
|
||||
/// Plugins providing `kind` for `language`, in declaration order.
|
||||
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (_, plugin, kinds, languages) in self.items.read().iter() {
|
||||
if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) {
|
||||
out.push(plugin.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
|
||||
}
|
||||
|
||||
/// One coherent declaration snapshot, preserving the first match per plugin.
|
||||
fn matching<T: FromIterator<(String, u64)>>(&self, kind: ProviderKind, language: &str) -> T {
|
||||
let items = self.items.read();
|
||||
let mut seen = HashSet::new();
|
||||
items
|
||||
.iter()
|
||||
.filter(|(_, plugin, kinds, languages)| {
|
||||
kinds.contains(&kind)
|
||||
&& languages.iter().any(|l| l == "*" || l == language)
|
||||
&& seen.insert(plugin.as_str())
|
||||
})
|
||||
.map(|(seq, plugin, ..)| (plugin.clone(), *seq))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
|
||||
self.matching(ProviderKind::Diagnostics, language)
|
||||
}
|
||||
|
||||
/// Whether any plugin provides `kind` (for any language).
|
||||
@@ -118,6 +133,9 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.contains(&ProviderKind::SemanticTokens) {
|
||||
pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?;
|
||||
}
|
||||
if d.kinds.contains(&ProviderKind::Diagnostics) {
|
||||
pkg.manifest.needs_api("contributes.providers: diagnostics", 3)?;
|
||||
}
|
||||
if d.kinds.contains(&ProviderKind::Hover) {
|
||||
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
|
||||
}
|
||||
@@ -202,7 +220,7 @@ pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
|
||||
|
||||
impl PluginHost {
|
||||
/// Usable plugins declaring `kind` for `language`.
|
||||
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
|
||||
pub(super) fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
|
||||
self.providers
|
||||
.plugins_for(kind, language)
|
||||
.into_iter()
|
||||
@@ -315,11 +333,12 @@ impl PluginHost {
|
||||
plugin_id: &str,
|
||||
doc: &ProviderDocument,
|
||||
action: CodeAction,
|
||||
invocation: ide_plugin::Invocation,
|
||||
) -> ide_core::IdeResult<CodeAction> {
|
||||
let d = Arc::new(doc.clone());
|
||||
let timeout = self.limits.timeout;
|
||||
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
|
||||
rt.resolve_code_action(&d, &action, timeout)
|
||||
self.code_call(plugin_id, invocation, "resolving the code action", move |rt, inv| {
|
||||
rt.resolve_code_action(&d, &action, inv, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,6 +378,59 @@ impl PluginHost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_diagnostics(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
|
||||
self.provide_project_diagnostics(None, doc, cancel)
|
||||
}
|
||||
|
||||
pub(super) fn diagnostic_plugins(
|
||||
&self,
|
||||
project: Option<&std::path::Path>,
|
||||
language: &str,
|
||||
) -> Vec<Arc<LoadedPlugin>> {
|
||||
self.provider_plugins(ProviderKind::Diagnostics, language)
|
||||
.into_iter()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn provide_project_diagnostics(
|
||||
&self,
|
||||
project: Option<&std::path::Path>,
|
||||
doc: &ProviderDocument,
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
|
||||
std::thread::scope(|scope| {
|
||||
let calls: Vec<_> = self
|
||||
.diagnostic_plugins(project, &doc.language)
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
scope.spawn(move || {
|
||||
if cancel.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let (actor, activated) = self.ensure_active(&p, None).ok()?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let (d, timeout) = (doc.clone(), self.limits.edit_hard);
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
actor.call_edit_cancellable(cancel, CANCEL_GRACE, self.limits.edit_soft, move |rt| {
|
||||
rt.provide_diagnostics(&d, timeout)
|
||||
});
|
||||
let items = self.finish_provider_call(&p, &actor, started, result)?;
|
||||
(!cancel.is_cancelled()).then(|| (p.id().to_owned(), items))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
calls.into_iter().filter_map(|call| call.join().ok().flatten()).collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
@@ -405,3 +477,41 @@ impl PluginHost {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_snapshots_preserve_order_first_generation_and_language_matching() {
|
||||
let providers = Arc::new(Providers::default());
|
||||
let declare = |plugin: &str, kind, language: &str| {
|
||||
providers.add(plugin.into(), Decl { kinds: vec![kind], languages: vec![language.into()] })
|
||||
};
|
||||
let mut declarations = Vec::new();
|
||||
declarations.push(declare("unrelated", ProviderKind::Hover, "*"));
|
||||
declarations.push(declare("other-language", ProviderKind::Diagnostics, "rust"));
|
||||
for i in 0..256 {
|
||||
let plugin = format!("plugin-{i}");
|
||||
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "text"));
|
||||
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "*"));
|
||||
}
|
||||
let expected: Vec<_> = (0..256).map(|i| format!("plugin-{i}")).collect();
|
||||
assert_eq!(providers.plugins_for(ProviderKind::Diagnostics, "text"), expected);
|
||||
let before = providers.diagnostic_generations("text");
|
||||
assert_eq!(before.len(), 256);
|
||||
assert_eq!(before["plugin-0"], 2);
|
||||
assert_eq!(providers.diagnostic_generations("rust").len(), 257);
|
||||
assert_eq!(providers.plugins_for(ProviderKind::Hover, "text"), ["unrelated"]);
|
||||
// Removing the first matching declaration must invalidate its generation,
|
||||
// even while a second declaration still provides the same language.
|
||||
drop(declarations.remove(2));
|
||||
let after = providers.diagnostic_generations("text");
|
||||
assert_eq!(after["plugin-0"], 3);
|
||||
assert_ne!(before["plugin-0"], after["plugin-0"]);
|
||||
drop(declarations);
|
||||
assert!(providers.diagnostic_generations("text").is_empty());
|
||||
let _reloaded = declare("plugin-0", ProviderKind::Diagnostics, "text");
|
||||
assert_ne!(providers.diagnostic_generations("text")["plugin-0"], after["plugin-0"]);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ pub const SETTINGS: &str = "settings";
|
||||
pub const VIEWS: &str = "views";
|
||||
pub const CUSTOM_EDITORS: &str = "customEditors";
|
||||
pub const FILE_ICONS: &str = "fileIcons";
|
||||
pub const MARKER_ICONS: &str = "markerIcons";
|
||||
pub const FILE_TEMPLATES: &str = "fileTemplates";
|
||||
pub const COMMIT_CHECKS: &str = "commitChecks";
|
||||
|
||||
@@ -399,6 +400,13 @@ struct ViewDecl {
|
||||
icon: Option<String>,
|
||||
#[serde(default = "right")]
|
||||
location: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
group: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
when: Option<String>,
|
||||
/// Show the selected tree/list item's detail-provider tabs beside it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
detail: bool,
|
||||
/// A web page of the package (`web/index.html`) instead of content the code renders.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
web: Option<String>,
|
||||
@@ -419,6 +427,15 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: ViewDecl = decode(VIEWS, decl)?;
|
||||
validate_item_id(&d.id, "view id")?;
|
||||
title(VIEWS, &d.title)?;
|
||||
if let Some(group) = &d.group {
|
||||
pkg.manifest.needs_api("contributes.views.group", 3)?;
|
||||
validate_item_id(group, "view group")?;
|
||||
}
|
||||
if let Some(when) = &d.when {
|
||||
pkg.manifest.needs_api("contributes.views.when", 3)?;
|
||||
validate_when(when).map_err(|e| format!("contributes.views: {e}"))?;
|
||||
}
|
||||
|
||||
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
|
||||
if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) {
|
||||
return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id));
|
||||
@@ -429,6 +446,12 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id));
|
||||
}
|
||||
}
|
||||
if d.detail {
|
||||
pkg.manifest.needs_api(&format!("view {:?}: `detail`", d.id), 3)?;
|
||||
if d.web.is_some() {
|
||||
return Err(format!("view {:?}: `detail` needs a tree or list rendered by the plugin (no `web`)", d.id));
|
||||
}
|
||||
}
|
||||
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
|
||||
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
|
||||
}
|
||||
@@ -540,17 +563,56 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct MarkerIconDecl {
|
||||
id: String,
|
||||
icon: PathBuf,
|
||||
#[serde(default)]
|
||||
icon_dark: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
pkg.manifest.needs_api("contributes.markerIcons", 3)?;
|
||||
let d: MarkerIconDecl = decode(MARKER_ICONS, decl)?;
|
||||
validate_item_id(&d.id, "marker icon id")?;
|
||||
if d.icon.to_string_lossy().len() > 256 {
|
||||
return Err("marker icon paths are up to 256 bytes".into());
|
||||
}
|
||||
let mut out = UiDecl::new(d.id.clone(), d.id.clone(), serde_json::json!({ "id": d.id, "icon": d.icon }));
|
||||
for (path, variant) in [(Some(&d.icon), "light"), (d.icon_dark.as_ref(), "dark")] {
|
||||
let Some(path) = path else { continue };
|
||||
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
|
||||
out.assets.push((format!("markerIcon/{}/{variant}", d.id), svg));
|
||||
out.files.push(path.clone());
|
||||
}
|
||||
// A changed SVG must reproject even when its declared path stays the same.
|
||||
let bytes = serde_json::to_vec(&out.assets).map_err(|e| e.to_string())?;
|
||||
out.json["revision"] = Value::String(blake3::hash(&bytes).to_hex().to_string());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct FileTemplateDecl {
|
||||
id: String,
|
||||
name: String,
|
||||
/// Without the dot.
|
||||
#[serde(default)]
|
||||
extension: String,
|
||||
file_name: Option<String>,
|
||||
variables: Option<Vec<FileTemplateVariable>>,
|
||||
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FileTemplateVariable {
|
||||
name: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
const MAX_TEMPLATE: usize = 256 << 10;
|
||||
|
||||
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
|
||||
@@ -559,8 +621,59 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
validate_item_id(&d.id, "file template id")?;
|
||||
title(FILE_TEMPLATES, &d.name)?;
|
||||
let what = format!("file template {:?}", d.id);
|
||||
if d.file_name.is_some() || d.variables.is_some() {
|
||||
pkg.manifest.needs_api("contributes.fileTemplates: fileName / variables", 3)?;
|
||||
}
|
||||
if let Some(name) = &d.file_name {
|
||||
let stem = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
|
||||
if name.is_empty()
|
||||
|| name.len() > 255
|
||||
|| name.trim() != name
|
||||
|| name.ends_with('.')
|
||||
|| matches!(name.as_str(), "." | "..")
|
||||
|| name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c))
|
||||
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& matches!(stem.as_bytes().get(3), Some(b'1'..=b'9'))
|
||||
&& stem.len() == 4)
|
||||
{
|
||||
return Err(format!("{what}: `fileName` must be a safe single file name"));
|
||||
}
|
||||
}
|
||||
let mut names = std::collections::HashSet::new();
|
||||
let variables = d.variables.unwrap_or_default();
|
||||
if variables.len() > 32 {
|
||||
return Err(format!("{what}: at most 32 variables are allowed"));
|
||||
}
|
||||
for variable in &variables {
|
||||
let name = &variable.name;
|
||||
if name.is_empty()
|
||||
|| name.len() > 64
|
||||
|| !name
|
||||
.bytes()
|
||||
.enumerate()
|
||||
.all(|(i, c)| c.is_ascii_alphabetic() || c == b'_' || i > 0 && c.is_ascii_digit())
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"NAME"
|
||||
| "FILE_NAME"
|
||||
| "PACKAGE"
|
||||
| "PACKAGE_NAME"
|
||||
| "PROJECT_NAME"
|
||||
| "USER"
|
||||
| "DATE"
|
||||
| "TIME"
|
||||
| "YEAR"
|
||||
| "END"
|
||||
)
|
||||
|| !names.insert(name)
|
||||
{
|
||||
return Err(format!("{what}: invalid, reserved or duplicate variable name {name:?}"));
|
||||
}
|
||||
title("file template variable prompt", &variable.prompt)?;
|
||||
}
|
||||
let ext = d.extension.trim_start_matches('.');
|
||||
if ext.is_empty()
|
||||
if (ext.is_empty() && d.file_name.is_none())
|
||||
|| ext.len() > 32
|
||||
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||
{
|
||||
@@ -570,7 +683,7 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
if body.len() > MAX_TEMPLATE {
|
||||
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
|
||||
}
|
||||
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body });
|
||||
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body, "fileName": d.file_name, "variables": variables });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
|
||||
out.files = vec![d.path.clone()];
|
||||
Ok(out)
|
||||
|
||||
@@ -100,6 +100,23 @@ pub struct WebPage {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// A web frame's API 1.3 PTY request. The frame never supplies an executable path or cwd.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebPtyCommand {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: std::collections::BTreeMap<String, String>,
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
}
|
||||
|
||||
pub(super) struct WebPtySession {
|
||||
id: ide_core::TerminalId,
|
||||
host: std::sync::Weak<crate::Host>,
|
||||
window: String,
|
||||
}
|
||||
|
||||
/// A file of a web view.
|
||||
pub struct WebFile {
|
||||
pub bytes: Vec<u8>,
|
||||
@@ -107,6 +124,138 @@ pub struct WebFile {
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Uses the normal PTY backend, without user shell fallback or startup commands.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_web_pty(
|
||||
&self,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
plugin_id: &str,
|
||||
view: &str,
|
||||
window: &str,
|
||||
command: WebPtyCommand,
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let mut sessions = self.web_ptys.lock();
|
||||
if self.shared.client_closed(window) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options = self.web_pty_options(plugin_id, view, window, command)?;
|
||||
let entries = sessions.entry(plugin_id.to_owned()).or_default();
|
||||
entries.retain(|s| s.host.upgrade().is_some_and(|h| h.terminals.contains(s.id)));
|
||||
if entries.len() >= 8 {
|
||||
return Err(IdeError::invalid("a plugin may own at most 8 PTYs"));
|
||||
}
|
||||
let cwd = options.cwd.0.clone();
|
||||
let info = host.terminals.spawn_program(
|
||||
options,
|
||||
host.owner_window(window).to_owned(),
|
||||
host.window_sink(window),
|
||||
Box::new(move |data| output(data)),
|
||||
)?;
|
||||
if !self
|
||||
.shared
|
||||
.workspace()
|
||||
.is_some_and(|workspace| workspace.project_of(window).as_ref() == Some(&cwd) && workspace.trusted(&cwd))
|
||||
{
|
||||
let _ = host.terminals.kill(info.id);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
entries.push(WebPtySession { id: info.id, host: std::sync::Arc::downgrade(host), window: window.to_owned() });
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub(super) fn forget_web_ptys(&self, window: &str) {
|
||||
for sessions in self.web_ptys.lock().values_mut() {
|
||||
sessions.retain(|session| {
|
||||
if session.window != window {
|
||||
return true;
|
||||
}
|
||||
if let Some(host) = session.host.upgrade() {
|
||||
let _ = host.terminals.kill(session.id);
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stop_web_ptys(&self, plugin: &str) {
|
||||
if let Some(sessions) = self.web_ptys.lock().remove(plugin) {
|
||||
for session in sessions {
|
||||
if let Some(host) = session.host.upgrade() {
|
||||
let _ = host.terminals.kill(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorizes a PTY immediately before spawning through the existing terminal backend.
|
||||
pub fn web_pty_options(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
view: &str,
|
||||
window: &str,
|
||||
command: WebPtyCommand,
|
||||
) -> IdeResult<ide_terminal::TerminalOptions> {
|
||||
self.web_page(plugin_id, view)?;
|
||||
let plugin = self.get(plugin_id)?;
|
||||
plugin.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
|
||||
let cap = ide_extension::Capability::Process(command.program.clone());
|
||||
if !plugin.meta.lock().granted.contains(&cap) {
|
||||
return Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::PermissionDenied,
|
||||
format!("{cap} capability not granted"),
|
||||
));
|
||||
}
|
||||
if command.program.is_empty()
|
||||
|| command.program.len() > 256
|
||||
|| command.program.trim() != command.program
|
||||
|| command.program.contains(['\0', '\r', '\n'])
|
||||
|| command.args.len() > 256
|
||||
|| command.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|
||||
|| !(2..=500).contains(&command.cols)
|
||||
|| !(1..=500).contains(&command.rows)
|
||||
{
|
||||
return Err(IdeError::invalid("invalid PTY program, arguments or dimensions"));
|
||||
}
|
||||
if command.env.len() > 64
|
||||
|| command.env.iter().any(|(k, v)| {
|
||||
k.is_empty()
|
||||
|| k.len() > 256
|
||||
|| k.contains(['=', '\0'])
|
||||
|| v.len() > 4096
|
||||
|| v.contains('\0')
|
||||
|| std::env::vars_os().any(|(inherited, _)| {
|
||||
if cfg!(windows) {
|
||||
inherited.to_string_lossy().eq_ignore_ascii_case(k)
|
||||
} else {
|
||||
inherited == k.as_str()
|
||||
}
|
||||
})
|
||||
})
|
||||
{
|
||||
return Err(IdeError::invalid("PTY environment may only add variables, within the size limits"));
|
||||
}
|
||||
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
|
||||
let cwd = workspace.project_of(window).ok_or_else(|| IdeError::invalid("no project in this window"))?;
|
||||
if !cwd.is_absolute() || !cwd.is_dir() {
|
||||
return Err(IdeError::invalid("the project directory no longer exists"));
|
||||
}
|
||||
if !workspace.trusted(&cwd) {
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
|
||||
}
|
||||
let program = self.program_for(plugin_id, &command.program)?;
|
||||
Ok(ide_terminal::TerminalOptions {
|
||||
cwd: cwd.into(),
|
||||
shell: Some(program.to_string_lossy().into_owned()),
|
||||
args: command.args,
|
||||
env: command.env,
|
||||
cols: command.cols,
|
||||
rows: command.rows,
|
||||
shell_integration: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated).
|
||||
fn web_dirs(&self, plugin: &str) -> Vec<String> {
|
||||
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct Project {
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
pub settings: Arc<SettingsStore>,
|
||||
pub documents: Arc<Documents>,
|
||||
pub(crate) plugin_diagnostics: Arc<crate::plugins::diagnostics::Diagnostics>,
|
||||
pub history: Arc<LocalHistory>,
|
||||
/// Undo stack of file move / rename refactorings.
|
||||
pub moves: FileMoves,
|
||||
@@ -519,7 +520,11 @@ impl Host {
|
||||
sink.clone(),
|
||||
);
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
let plugin_diagnostics =
|
||||
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root);
|
||||
documents.add_observer(plugin_diagnostics.clone());
|
||||
let project = Arc::new(Project {
|
||||
plugin_diagnostics,
|
||||
trusted,
|
||||
root,
|
||||
sink,
|
||||
@@ -665,7 +670,11 @@ impl Host {
|
||||
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
let plugin_diagnostics =
|
||||
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &dir);
|
||||
documents.add_observer(plugin_diagnostics.clone());
|
||||
let project = Arc::new(Project {
|
||||
plugin_diagnostics,
|
||||
trusted: Arc::new(AtomicBool::new(false)),
|
||||
sink,
|
||||
languages,
|
||||
|
||||
@@ -16,8 +16,14 @@ pub struct TrustDecisions {
|
||||
pub struct TrustStore {
|
||||
file: PathBuf,
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
|
||||
inherited: Mutex<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
|
||||
/// there: the project folder in the container, trusted without asking again. Never persisted.
|
||||
pub const TRUSTED_ROOT_ENV: &str = "OPENIDEA_TRUSTED_ROOT";
|
||||
|
||||
impl TrustStore {
|
||||
pub fn load(config: &Path, data: &Path) -> Self {
|
||||
let file = data.join("project-trust.json");
|
||||
@@ -34,7 +40,7 @@ impl TrustStore {
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state) }
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
@@ -45,7 +51,19 @@ impl TrustStore {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let decisions = self.decisions()?;
|
||||
Ok(!decisions.restricted.contains(&root)
|
||||
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
|
||||
&& (decisions.projects.contains(&root)
|
||||
|| decisions.locations.iter().any(|p| root.starts_with(p))
|
||||
|| self.inherited.lock().contains(&root)))
|
||||
}
|
||||
|
||||
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
|
||||
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let mut inherited = self.inherited.lock();
|
||||
if !inherited.contains(&root) {
|
||||
inherited.push(root);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Real Wasm provider dispatch and the document-edit path used locally and over RPC.
|
||||
use ide_core::{CancellationToken, ChangeSet, Diagnostic, IdeEvent, Pos, Range, TextChange};
|
||||
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, set_editor_providers};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_plugin::{Invocation, Limits, ProviderDocument};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const ID: &str = "test.diagnostics";
|
||||
const SOURCE: &str = "plugin-provider:test.diagnostics";
|
||||
|
||||
fn fixture() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics")
|
||||
}
|
||||
|
||||
fn plugins(dir: &Path, limits: Limits) -> Arc<PluginHost> {
|
||||
let host = Arc::new(PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap());
|
||||
host.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
host
|
||||
}
|
||||
|
||||
fn generous() -> Limits {
|
||||
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() }
|
||||
}
|
||||
|
||||
fn doc(root: &Path, language: &str, text: &str) -> ProviderDocument {
|
||||
ProviderDocument { path: root.join("a.txt"), language: language.into(), version: 42, text: text.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_languages_receive_versioned_text_and_utf16_ranges() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = plugins(tmp.path(), generous());
|
||||
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "😀BAD\nBAD"), &CancellationToken::new());
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, ID);
|
||||
let items = &result[0].1;
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].range, Range::new(Pos::new(0, 2), Pos::new(0, 5)));
|
||||
assert_eq!(items[1].range, Range::new(Pos::new(1, 0), Pos::new(1, 3)));
|
||||
assert_eq!(items[0].message, "BAD in text at version 42");
|
||||
assert_eq!(items[0].code.as_deref(), Some("bad-word"));
|
||||
assert_eq!(items[0].server.as_deref(), Some(ID));
|
||||
assert!(host.provide_diagnostics(&doc(tmp.path(), "rust", "BAD"), &CancellationToken::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_diagnostics_have_the_same_bounds_and_tags_as_manual_publish() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = plugins(tmp.path(), generous());
|
||||
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "limits"), &CancellationToken::new());
|
||||
let items = &result[0].1;
|
||||
assert_eq!(items.len(), 5000);
|
||||
assert_eq!(items[0].message.len(), 4096);
|
||||
assert_eq!(items[0].source.as_ref().unwrap().len(), 128);
|
||||
assert_eq!(items[0].code.as_ref().unwrap().len(), 128);
|
||||
assert!(items[0].unnecessary && items[0].deprecated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_export_does_not_need_project_read_permission() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let package = tmp.path().join("no-capability");
|
||||
std::fs::create_dir_all(package.join("dist")).unwrap();
|
||||
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
|
||||
std::fs::write(package.join("plugin.toml"), manifest.replace("[capabilities]\nrequired = [\"project.read\"]", ""))
|
||||
.unwrap();
|
||||
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
|
||||
host.install_from_dir(&package, &Consent::default()).unwrap();
|
||||
assert_eq!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).len(), 1);
|
||||
assert!(host.run_command(ID, "publish", Some("/project/a.txt"), Invocation::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_is_an_api_1_3_addition() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let package = tmp.path().join("old");
|
||||
std::fs::create_dir_all(package.join("dist")).unwrap();
|
||||
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
|
||||
std::fs::write(package.join("plugin.toml"), manifest.replace("^1.3", "^1.0")).unwrap();
|
||||
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
|
||||
assert!(host.install_from_dir(&package, &Consent::all()).unwrap_err().message.contains("new in plugin API 1.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_and_cancelled_answers_are_dropped_without_disabling_the_plugin() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = plugins(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() });
|
||||
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "slow BAD"), &CancellationToken::new()).is_empty());
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
|
||||
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_queued_provider_does_not_interrupt_another_windows_command() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = plugins(tmp.path(), generous());
|
||||
host.run_command(ID, "calls", None, Invocation::default()).unwrap();
|
||||
let waiter = {
|
||||
let host = host.clone();
|
||||
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
|
||||
};
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let cancel = CancellationToken::new();
|
||||
let canceller = {
|
||||
let cancel = cancel.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
cancel.cancel();
|
||||
})
|
||||
};
|
||||
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
|
||||
canceller.join().unwrap();
|
||||
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
|
||||
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_requests_expire_in_the_queue_without_running_after_a_command() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = plugins(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() });
|
||||
host.run_command(ID, "calls", None, Invocation::default()).unwrap();
|
||||
let waiter = {
|
||||
let host = host.clone();
|
||||
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
|
||||
};
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).is_empty());
|
||||
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
|
||||
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
|
||||
assert_eq!(host.info_of(ID).unwrap().stats.slow_calls, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_on_cancels_a_running_provider_before_its_hard_deadline() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = plugins(tmp.path(), Limits { fuel: u64::MAX, ..generous() });
|
||||
host.run_command(ID, "calls", None, Invocation::default()).unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let canceller = {
|
||||
let cancel = cancel.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
cancel.cancel();
|
||||
})
|
||||
};
|
||||
let started = Instant::now();
|
||||
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "spin BAD"), &cancel).is_empty());
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
canceller.join().unwrap();
|
||||
}
|
||||
|
||||
// This executable has one edit-path test because editor providers are installed once per process.
|
||||
type Events = Arc<Mutex<Vec<(String, IdeEvent)>>>;
|
||||
|
||||
fn runtime_host(base: &Path, events: Events) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |window| {
|
||||
let (events, window) = (events.clone(), window.to_owned());
|
||||
Arc::new(move |event: IdeEvent| events.lock().push((window.clone(), event)))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn latest(events: &Events, window: &str, source: &str) -> Option<Vec<Diagnostic>> {
|
||||
events.lock().iter().rev().find_map(|(w, e)| match e {
|
||||
IdeEvent::Diagnostics { source: s, items, .. } if w == window && s == source => Some(items.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for(events: &Events, window: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
|
||||
wait_for_source(events, window, SOURCE, predicate).await;
|
||||
}
|
||||
|
||||
async fn wait_for_source(events: &Events, window: &str, source: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if latest(events, window, source).is_some_and(|items| predicate(&items)) {
|
||||
return;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5), "missing diagnostics for {window}");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(host: &Host, window: &str, info: &ide_workspace::DocumentInfo, text: &str) -> u32 {
|
||||
let project = host.project(window).unwrap();
|
||||
let snapshot = project.documents.snapshot(info.id).unwrap();
|
||||
let len = snapshot.text.encode_utf16().count() as u32;
|
||||
ops::doc::edit(
|
||||
host,
|
||||
window,
|
||||
info.id,
|
||||
snapshot.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: len, insert: text.into() }] },
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window_lifetimes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let path = root.join("a.txt");
|
||||
std::fs::write(&path, "clean").unwrap();
|
||||
let events: Events = Arc::default();
|
||||
let host = runtime_host(&root.join("app"), events.clone());
|
||||
let plugins = plugins(&root, generous());
|
||||
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
||||
set_editor_providers(Some(&plugins));
|
||||
let left = host.open_light("left", &path).unwrap();
|
||||
host.open_light("right", &path).unwrap();
|
||||
let a = ops::doc::open(&host, "left", path.clone()).await.unwrap();
|
||||
let b = ops::doc::open(&host, "right", path.clone()).await.unwrap();
|
||||
let second_tab = ops::doc::open(&host, "right", path.clone()).await.unwrap();
|
||||
assert_eq!(b.id, second_tab.id);
|
||||
wait_for(&events, "left", |items| items.is_empty()).await;
|
||||
wait_for(&events, "right", |items| items.is_empty()).await;
|
||||
|
||||
let calls: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
|
||||
events.lock().clear();
|
||||
replace(&host, "left", &a, "BAD");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let version = replace(&host, "left", &a, "😀BAD");
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
assert!(latest(&events, "left", SOURCE).is_none(), "300ms after the latest edit");
|
||||
wait_for(&events, "left", |items| !items.is_empty()).await;
|
||||
assert_eq!(
|
||||
latest(&events, "left", SOURCE).unwrap()[0].message,
|
||||
format!("BAD in {} at version {version}", a.language)
|
||||
);
|
||||
assert!(latest(&events, "right", SOURCE).is_none(), "other window has its own buffer");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "clean", "no save required");
|
||||
let after: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
|
||||
assert_eq!(after, calls + 1, "the edit burst was coalesced");
|
||||
|
||||
plugins.run_command(ID, "publish", path.to_str(), Invocation::default()).unwrap();
|
||||
let manual_window = events.lock().iter().find_map(|(window, event)| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "plugin:test.diagnostics" && !items.is_empty()).then(|| window.clone())).unwrap();
|
||||
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
|
||||
replace(&host, "left", &a, "clean again");
|
||||
wait_for(&events, "left", |items| items.is_empty()).await;
|
||||
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
|
||||
|
||||
events.lock().clear();
|
||||
replace(&host, "left", &a, "slow BAD");
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
replace(&host, "left", &a, "latest clean");
|
||||
wait_for(&events, "left", |items| items.is_empty()).await;
|
||||
assert!(
|
||||
!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())),
|
||||
"superseded in-flight answers never publish"
|
||||
);
|
||||
|
||||
replace(&host, "right", &b, "BAD right");
|
||||
wait_for(&events, "right", |items| !items.is_empty()).await;
|
||||
ops::doc::close(&host, "right", b.id).unwrap();
|
||||
assert!(!latest(&events, "right", SOURCE).unwrap().is_empty(), "another pane still owns this document");
|
||||
ops::doc::close(&host, "right", second_tab.id).unwrap();
|
||||
wait_for(&events, "right", |items| items.is_empty()).await;
|
||||
replace(&host, "left", &a, "BAD left");
|
||||
wait_for(&events, "left", |items| !items.is_empty()).await;
|
||||
plugins.set_enabled(ID, false).unwrap();
|
||||
wait_for(&events, "left", |items| items.is_empty()).await;
|
||||
plugins.set_enabled(ID, true).unwrap();
|
||||
|
||||
events.lock().clear();
|
||||
replace(&host, "left", &a, "slow BAD closing");
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
host.close_project("left");
|
||||
// Keep the old Project alive deliberately: window reuse must still cancel it.
|
||||
assert_eq!(left.documents.snapshot(a.id).unwrap().text, "slow BAD closing");
|
||||
host.open_light("left", &path).unwrap();
|
||||
let reopened = ops::doc::open(&host, "left", path.clone()).await.unwrap();
|
||||
wait_for(&events, "left", |items| items.is_empty()).await;
|
||||
assert!(!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())));
|
||||
// Project plugins receive only snapshots from the project that owns their declaration.
|
||||
let project_package = root.join(ide_host::plugins::DEV_PLUGINS_DIR).join("diagnostics");
|
||||
std::fs::create_dir_all(project_package.join("dist")).unwrap();
|
||||
std::fs::copy(fixture().join("dist/plugin.wasm"), project_package.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
|
||||
std::fs::write(project_package.join("plugin.toml"), manifest.replace(ID, "project.diagnostics")).unwrap();
|
||||
plugins.set_project_policy(Arc::new(|_| true));
|
||||
plugins.sync_project(&root);
|
||||
plugins.grant("project.diagnostics", &Consent::all()).unwrap();
|
||||
replace(&host, "left", &reopened, "BAD in project");
|
||||
wait_for(&events, "left", |items| !items.is_empty()).await;
|
||||
let project_source = "plugin-provider:project.diagnostics";
|
||||
wait_for_source(&events, "left", project_source, |items| !items.is_empty()).await;
|
||||
let elsewhere = root.join("other");
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
let other_path = elsewhere.join("other.txt");
|
||||
std::fs::write(&other_path, "BAD outside").unwrap();
|
||||
host.open_light("elsewhere", &other_path).unwrap();
|
||||
ops::doc::open(&host, "elsewhere", other_path).await.unwrap();
|
||||
wait_for(&events, "elsewhere", |items| !items.is_empty()).await;
|
||||
assert!(latest(&events, "elsewhere", project_source).is_none());
|
||||
plugins.restrict_project(&root);
|
||||
assert!(latest(&events, "left", project_source).unwrap().is_empty());
|
||||
// A completed answer may still be waiting for a sibling when its plugin crashes.
|
||||
let sibling = root.join("slow-sibling");
|
||||
std::fs::create_dir_all(sibling.join("dist")).unwrap();
|
||||
std::fs::copy(fixture().join("dist/plugin.wasm"), sibling.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
|
||||
std::fs::write(
|
||||
sibling.join("plugin.toml"),
|
||||
manifest.replace(ID, "test.slow").replace("[\"text\", \"plaintext\"]", "[\"plaintext\"]"),
|
||||
)
|
||||
.unwrap();
|
||||
plugins.install_from_dir(&sibling, &Consent::all()).unwrap();
|
||||
plugins.run_command("test.slow", "calls", None, Invocation::default()).unwrap();
|
||||
let calls = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap();
|
||||
events.lock().clear();
|
||||
replace(&host, "left", &reopened, "BAD completed before crash");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let waiter = {
|
||||
let plugins = plugins.clone();
|
||||
std::thread::spawn(move || plugins.run_command("test.slow", "wait", None, Invocation::default()))
|
||||
};
|
||||
let started = Instant::now();
|
||||
while plugins.run_command(ID, "calls", None, Invocation::default()).unwrap() == calls {
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
assert!(
|
||||
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
|
||||
"the sibling still holds the result: {:?}",
|
||||
latest(&events, "left", SOURCE)
|
||||
);
|
||||
assert!(plugins.provide_diagnostics(&doc(&root, "text", "panic"), &CancellationToken::new()).is_empty());
|
||||
waiter.join().unwrap().unwrap();
|
||||
wait_for_source(&events, "left", "plugin-provider:test.slow", |items| !items.is_empty()).await;
|
||||
assert!(
|
||||
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
|
||||
"a crash invalidates completed answers"
|
||||
);
|
||||
ops::doc::close(&host, "left", reopened.id).unwrap();
|
||||
host.shutdown().await;
|
||||
set_editor_providers(None);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! The synchronous workspace import uses the real project index, also from a provider.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::{NullSink, Pos, Range};
|
||||
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, PluginWorkspace};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_plugin::{Invocation, Limits, ProviderDocument};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
_runtime: tokio::runtime::Runtime,
|
||||
host: Arc<Host>,
|
||||
plugins: PluginHost,
|
||||
root: PathBuf,
|
||||
other: PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(capability: bool) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let (root, other) = (base.join("project"), base.join("other"));
|
||||
for dir in [&root, &other] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("Order.php"), "<?php class OrderController { public function item() {} }\n").unwrap();
|
||||
std::fs::write(root.join("Main.java"), "class OrderService { void serve() {} }\n").unwrap();
|
||||
std::fs::write(other.join("Order.php"), "<?php class OrderElsewhere {}\n").unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
|
||||
let syntax = OnceLock::new();
|
||||
syntax.set(Arc::new(ide_language::Syntax::new())).ok().unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::new(syntax),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: runtime.handle().clone(),
|
||||
project_environment: false,
|
||||
}));
|
||||
// Restricted projects need no LSP/process startup for index queries.
|
||||
{
|
||||
let _guard = runtime.enter();
|
||||
for (window, dir) in [("main", &root), ("other", &other)] {
|
||||
let project = host.open_project(window, dir).unwrap();
|
||||
assert!(project.index.wait_idle(WAIT));
|
||||
}
|
||||
}
|
||||
let plugins = PluginHost::new(
|
||||
base.join("plugins"),
|
||||
Arc::new(|_: Option<&str>, _| {}),
|
||||
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() },
|
||||
)
|
||||
.unwrap();
|
||||
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
||||
plugins.set_project_policy(Arc::new(|_| true));
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/symbols");
|
||||
let package = base.join("fixture");
|
||||
std::fs::create_dir_all(package.join("dist")).unwrap();
|
||||
std::fs::copy(source.join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(source.join("plugin.toml")).unwrap();
|
||||
let manifest =
|
||||
if capability { manifest } else { manifest.replace("required = [\"project.read\"]", "required = []") };
|
||||
std::fs::write(package.join("plugin.toml"), manifest).unwrap();
|
||||
plugins.install_from_dir(&package, &Consent::all()).unwrap();
|
||||
Self { _tmp: tmp, _runtime: runtime, host, plugins, root, other }
|
||||
}
|
||||
|
||||
fn invoke(&self, command: &str, root: &Path, query: &str) -> ide_core::IdeResult<Option<String>> {
|
||||
self.plugins.run_command(
|
||||
"test.symbols",
|
||||
command,
|
||||
Some(query),
|
||||
Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() },
|
||||
)
|
||||
}
|
||||
|
||||
fn search(&self, query: &str) -> String {
|
||||
self.invoke("symbols", &self.root, query).unwrap().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
self.host.close_project("main");
|
||||
self.host.close_project("other");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_reach_commands_and_providers_without_a_language_server() {
|
||||
let f = Fixture::new(true);
|
||||
let all = f.search("Order|10");
|
||||
assert!(all.contains("OrderController\tclass\t"), "{all}");
|
||||
assert!(all.contains("OrderService\tclass\t"), "{all}");
|
||||
assert!(!all.contains("OrderElsewhere"));
|
||||
assert_eq!(f.search("OCo|1").lines().count(), 1);
|
||||
assert!(f.search("OrderController.item|1").starts_with("item\tmethod\tOrderController\t"));
|
||||
assert!(f.search("|10").is_empty());
|
||||
assert!(f.search("Order|0").is_empty());
|
||||
assert!(f.search("missing|10").is_empty());
|
||||
assert!(f.search("Order|4294967295").contains("OrderController"));
|
||||
assert!(
|
||||
f.invoke("symbols", &f.root, &format!("{}|1", "é".repeat(129)))
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("256 UTF-8 bytes")
|
||||
);
|
||||
let other = f.invoke("symbols", &f.other, "Order|10").unwrap().unwrap();
|
||||
assert!(other.contains("OrderElsewhere") && !other.contains("OrderController"));
|
||||
for language in ["php", "java"] {
|
||||
let targets = f.plugins.provide_definition(
|
||||
&ProviderDocument {
|
||||
path: f.root.join(if language == "php" { "Usage.php" } else { "Usage.java" }),
|
||||
language: language.into(),
|
||||
version: 1,
|
||||
text: "OCo".into(),
|
||||
},
|
||||
Pos::new(0, 3),
|
||||
);
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].path, f.root.join("Order.php"));
|
||||
let indexed = HostWorkspace(Arc::downgrade(&f.host))
|
||||
.symbols(&f.root, "OCo", 1, &CancellationToken::new(), Instant::now() + WAIT)
|
||||
.unwrap();
|
||||
assert_eq!(targets[0].range, indexed[0].location.range);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reindex_and_closed_project_replace_results_and_invalidate_retained_handles() {
|
||||
let f = Fixture::new(true);
|
||||
f.invoke("remember", &f.root, "").unwrap();
|
||||
let p = f.host.project("main").unwrap();
|
||||
let path = f.root.join("Order.php");
|
||||
std::fs::write(&path, "<?php /*😀*/ class UpdatedController {}\n").unwrap();
|
||||
p.index.update(std::slice::from_ref(&path), &[]);
|
||||
assert!(p.index.wait_idle(WAIT));
|
||||
assert!(!f.search("Order|10").contains("OrderController"));
|
||||
let symbols = HostWorkspace(Arc::downgrade(&f.host))
|
||||
.symbols(&f.root, "UpdatedController", 1, &CancellationToken::new(), Instant::now() + WAIT)
|
||||
.unwrap();
|
||||
assert_eq!(symbols.len(), 1);
|
||||
assert_eq!(symbols[0].location.range, Range::new(Pos::new(0, 19), Pos::new(0, 36)));
|
||||
assert!(f.search("Updated|10").contains("0:19-0:36"));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
p.index.update(&[], &[path]);
|
||||
assert!(p.index.wait_idle(WAIT));
|
||||
assert!(f.search("Updated|10").is_empty());
|
||||
f.host.close_project("main");
|
||||
let error = f.invoke("remembered", &f.root, "Order|10").unwrap_err();
|
||||
assert!(error.message.contains("no longer open"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_limits_are_capped_before_searching_the_index() {
|
||||
let f = Fixture::new(true);
|
||||
let project = f.host.project("main").unwrap();
|
||||
let path = f.root.join("Bulk.java");
|
||||
let text = (0..1005).map(|i| format!("class BulkSymbol{i} {{}}\n")).collect::<String>();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
project.index.update(&[path], &[]);
|
||||
assert!(project.index.wait_idle(WAIT));
|
||||
assert_eq!(f.search("BulkSymbol|4294967295").lines().count(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_queries_require_project_read_even_with_an_invocation_project() {
|
||||
let f = Fixture::new(false);
|
||||
let error = f.invoke("symbols", &f.root, "Order|10").unwrap_err();
|
||||
assert!(error.message.contains("project.read"), "{error}");
|
||||
assert!(
|
||||
f.plugins
|
||||
.provide_definition(
|
||||
&ProviderDocument {
|
||||
path: f.root.join("Usage.php"),
|
||||
language: "php".into(),
|
||||
version: 1,
|
||||
text: "OCo".into(),
|
||||
},
|
||||
Pos::new(0, 3)
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_search_excludes_external_content_roots_before_ranking() {
|
||||
let f = Fixture::new(true);
|
||||
let project = f.host.project("main").unwrap();
|
||||
std::fs::write(f.other.join("Exact.php"), "<?php class Order {}\n").unwrap();
|
||||
project.index.set_scope(vec![f.other.clone()], Vec::new());
|
||||
assert!(project.index.wait_idle(WAIT));
|
||||
assert_eq!(project.index.find_symbols("OrderElsewhere", ide_index::SymbolFilter::All, 1).len(), 1);
|
||||
assert!(f.search("OrderElsewhere|10").is_empty());
|
||||
// A better external match must not displace a readable fuzzy match.
|
||||
let result = f.search("Order|1");
|
||||
assert!(result.contains("OrderController") || result.contains("OrderService"), "{result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_symbol_search_honours_cancellation_and_deadlines() {
|
||||
let f = Fixture::new(true);
|
||||
let workspace = HostWorkspace(Arc::downgrade(&f.host));
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
workspace.symbols(&f.root, "Order", 10, &cancel, Instant::now() + WAIT),
|
||||
Err(ide_plugin::HostError::Cancelled)
|
||||
);
|
||||
let error = workspace.symbols(&f.root, "Order", 10, &CancellationToken::new(), Instant::now()).unwrap_err();
|
||||
assert!(error.to_string().contains("timed out"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symbol_search_excludes_updates_through_escaping_symlink_ancestors() {
|
||||
let f = Fixture::new(true);
|
||||
let project = f.host.project("main").unwrap();
|
||||
std::fs::write(f.other.join("Secret.php"), "<?php class SecretSymbol {}\n").unwrap();
|
||||
let dir = f.root.join("linked");
|
||||
std::os::unix::fs::symlink(&f.other, &dir).unwrap();
|
||||
project.index.update(&[dir.join("Secret.php")], &[]);
|
||||
assert!(project.index.wait_idle(WAIT));
|
||||
assert!(f.search("Secret|1").is_empty());
|
||||
}
|
||||
@@ -22,6 +22,7 @@ struct TestWorkspace {
|
||||
other_roots: Mutex<Vec<PathBuf>>,
|
||||
documents: Arc<ide_workspace::Documents>,
|
||||
trusted: bool,
|
||||
project_queries_left: Mutex<Option<usize>>,
|
||||
/// (producer, path) → published diagnostics.
|
||||
diagnostics: Mutex<std::collections::BTreeMap<(String, PathBuf), Vec<ide_core::Diagnostic>>>,
|
||||
/// plugin → its settings as the user set them.
|
||||
@@ -39,6 +40,7 @@ impl TestWorkspace {
|
||||
other_roots: Mutex::default(),
|
||||
documents: Arc::new(documents),
|
||||
trusted: true,
|
||||
project_queries_left: Mutex::new(None),
|
||||
diagnostics: Mutex::default(),
|
||||
settings: Mutex::default(),
|
||||
})
|
||||
@@ -93,6 +95,12 @@ impl PluginWorkspace for TestWorkspace {
|
||||
self.trusted
|
||||
}
|
||||
fn project_of(&self, _window: &str) -> Option<PathBuf> {
|
||||
if let Some(left) = self.project_queries_left.lock().as_mut() {
|
||||
if *left == 0 {
|
||||
return None;
|
||||
}
|
||||
*left -= 1;
|
||||
}
|
||||
Some(self.root.clone())
|
||||
}
|
||||
fn publish_diagnostics(
|
||||
@@ -551,6 +559,103 @@ fn fs_write_reaches_only_the_granted_paths() {
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
/// A program kept running behind a handle: the plugin writes its stdin, closes
|
||||
/// it, and waits for the exit status and what the program printed. A one-off run
|
||||
/// passes the environment it names.
|
||||
#[test]
|
||||
fn a_plugin_keeps_a_process_running_behind_a_handle() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&misbehave_pkg());
|
||||
f.host.set_capability("misbehave", "process:git", false).unwrap();
|
||||
assert!(f.run("misbehave", "envrun", Some("legacy")).unwrap_err().contains("process:git"));
|
||||
for command in ["envrun", "envstart", "child"] {
|
||||
let err = f.run("misbehave", command, None).unwrap_err();
|
||||
assert!(err.contains("process:git"), "{command}: {err}");
|
||||
}
|
||||
// The fixture declares `process:git` as optional: it needs a program that
|
||||
// reads stdin and reports its environment. Machines running these tests have
|
||||
// git; without one, there is nothing to run and nothing to check.
|
||||
if f.host.set_capability("misbehave", "process:git", true).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let out = f.run("misbehave", "child", None).expect("the handle works").expect("a result");
|
||||
let (hash, status) = out.split_once(' ').unwrap_or_else(|| panic!("{out}"));
|
||||
assert_eq!(hash.len(), 40, "a sha1 of what was written: {out}");
|
||||
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "{out}");
|
||||
assert_eq!(status, "Some(0)", "it ended by itself: {out}");
|
||||
|
||||
assert!(f.run("misbehave", "envrun", Some("legacy")).unwrap().unwrap().starts_with("git version"));
|
||||
let ident = f.run("misbehave", "envrun", None).expect("a run with extra environment").expect("a result");
|
||||
assert_eq!(ident.trim(), "added by the plugin");
|
||||
assert_eq!(f.run("misbehave", "envstart", None).unwrap().unwrap().trim(), "added by the plugin");
|
||||
for command in ["envdeny-run", "envdeny-start"] {
|
||||
let inherited = std::env::vars().find(|(key, _)| key != "PATH" && !key.contains('=')).unwrap().0;
|
||||
for key in ["PATH", inherited.as_str(), "", "BAD=KEY", "BAD\0KEY"] {
|
||||
let err = f.run("misbehave", command, Some(key)).unwrap_err();
|
||||
assert!(err.contains("environment variable"), "{command} {key:?}: {err}");
|
||||
}
|
||||
}
|
||||
// Dropping a resource returns the slot; the limit is simultaneous handles.
|
||||
for _ in 0..10 {
|
||||
f.run("misbehave", "childkeep", None).unwrap();
|
||||
assert_eq!(f.run("misbehave", "childpoll", None).unwrap().as_deref(), Some("None"));
|
||||
f.run("misbehave", "childdrop", None).unwrap();
|
||||
}
|
||||
for _ in 0..8 {
|
||||
f.run("misbehave", "childkeep", None).unwrap();
|
||||
}
|
||||
assert!(f.run("misbehave", "childkeep", None).unwrap_err().contains("at most 8"));
|
||||
f.run("misbehave", "childdrop", None).unwrap();
|
||||
f.run("misbehave", "childkeep", None).unwrap();
|
||||
f.host.forget_client("main");
|
||||
assert!(f.run("misbehave", "childpoll", None).unwrap_err().contains("no process"));
|
||||
assert!(f.run("misbehave", "childkeep", None).is_err(), "late starts are cancelled");
|
||||
f.host.reopen_client("main");
|
||||
f.run("misbehave", "childdrop", None).unwrap();
|
||||
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn plugin_processes_end_with_their_owner() {
|
||||
for mode in ["disable", "unload", "shutdown", "reclaim", "trap", "window"] {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
|
||||
f.install(&misbehave_pkg());
|
||||
f.host.set_capability("misbehave", "process:git", true).unwrap();
|
||||
let marker = format!("--path={}/process-{mode}", f.project.display());
|
||||
let alive = || {
|
||||
let ps = std::process::Command::new("ps").args(["-axo", "command="]).output().unwrap();
|
||||
assert!(ps.status.success());
|
||||
String::from_utf8_lossy(&ps.stdout).lines().any(|line| line.contains(&marker))
|
||||
};
|
||||
f.run("misbehave", "childkeep", marker.strip_prefix("--path=")).unwrap();
|
||||
assert!(alive(), "{mode}: the process started");
|
||||
match mode {
|
||||
"disable" => f.host.set_enabled("misbehave", false).unwrap(),
|
||||
"unload" => f.host.uninstall("misbehave").unwrap(),
|
||||
"shutdown" => f.host.shutdown(),
|
||||
"reclaim" => {
|
||||
std::thread::sleep(Duration::from_millis(75));
|
||||
f.host.tick();
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
}
|
||||
"trap" => {
|
||||
assert!(f.run("misbehave", "panic", None).is_err());
|
||||
}
|
||||
"window" => f.host.forget_client("main"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while alive() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(!alive(), "{mode}: the process must end with its owner");
|
||||
}
|
||||
}
|
||||
|
||||
/// A missing capability is an error the plugin can handle, not a trap.
|
||||
#[test]
|
||||
fn ungranted_capabilities_are_denied_not_trapped() {
|
||||
@@ -827,7 +932,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
|
||||
assert_eq!(f.command_titles("misbehave").len(), 14, "contributions stay");
|
||||
assert_eq!(f.command_titles("misbehave").len(), 23, "contributions stay");
|
||||
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
|
||||
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
|
||||
|
||||
@@ -1092,6 +1197,25 @@ fn untrusted_or_unconfirmed_project_plugins_never_run() {
|
||||
assert!(f.host.reload("misbehave").is_err());
|
||||
assert!(f.host.set_enabled("misbehave", true).is_err());
|
||||
assert!(f.run("misbehave", "echo", None).is_err());
|
||||
let doc = ide_plugin::ProviderDocument {
|
||||
path: f.project.join("a.txt"),
|
||||
language: "text".into(),
|
||||
version: 1,
|
||||
text: "prompts".into(),
|
||||
};
|
||||
let action = ide_plugin::CodeAction {
|
||||
title: "Ask input".into(),
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits: Vec::new(),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: Some("input".into()),
|
||||
file_action: false,
|
||||
};
|
||||
let error = f.host.resolve_code_action("misbehave", &doc, action, f.inv()).unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"), "{}", error.message);
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
f.host.sync_project(&f.project);
|
||||
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
|
||||
}
|
||||
@@ -1831,6 +1955,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
git(&f.project, &["init", "-b", "main"]);
|
||||
f.install(&official("git-toolbox"));
|
||||
let untrusted = Arc::new(TestWorkspace {
|
||||
project_queries_left: Mutex::new(None),
|
||||
root: f.project.clone(),
|
||||
other_roots: Mutex::default(),
|
||||
documents: f.workspace.documents.clone(),
|
||||
@@ -2252,6 +2377,86 @@ fn view_item_menus_name_a_declared_view() {
|
||||
assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_and_grouped_views_need_api_1_3() {
|
||||
for field in ["group = \"spring\"", "when = \"workspaceContains:pom.xml || plugin.hello.ready\""] {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old =
|
||||
hello_variant(tmp.path(), |m| m.replace("location = \"right\"", &format!("location = \"right\"\n{field}")));
|
||||
let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3"), "{err:?}");
|
||||
}
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(tmp.path(), |m| {
|
||||
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"").replace(
|
||||
"location = \"right\"",
|
||||
"location = \"right\"\ngroup = \"spring\"\nwhen = \"workspaceContains:pom.xml || plugin.hello.ready\"",
|
||||
)
|
||||
});
|
||||
let info = f.install(&pkg);
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "declaring views must not activate code");
|
||||
let view = f.host.ui_items("views").remove(0);
|
||||
assert_eq!(view.decl["group"], "spring");
|
||||
assert_eq!(view.decl["when"], "workspaceContains:pom.xml || plugin.hello.ready");
|
||||
f.host.set_enabled("hello", false).unwrap();
|
||||
assert!(f.host.ui_items("views").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_views_fixture_renders_and_scopes_context_and_reveal() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("grouped-views"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "views stay lazy");
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let views = f.host.ui_items("views");
|
||||
assert_eq!(views.len(), 3);
|
||||
assert_eq!(views[0].decl["group"], "demo");
|
||||
assert_eq!(views[1].decl["when"], "workspaceContains:grouped-views.txt");
|
||||
f.take_events();
|
||||
f.run("grouped-views", "show", None).unwrap();
|
||||
assert!(f.take_events().iter().any(|(client, event)| matches!(
|
||||
event,
|
||||
PluginEvent::ContextChanged { plugin_id, key, value }
|
||||
if client.as_deref() == Some("main") && plugin_id == "grouped-views"
|
||||
&& key == "ready" && value.as_deref() == Some("true")
|
||||
)));
|
||||
assert!(f.host.context_keys(Some("other")).is_empty());
|
||||
for view in ["overview", "files", "legacy"] {
|
||||
let rendered = f.host.render_view("grouped-views", view, None, f.inv()).unwrap();
|
||||
assert!(matches!(rendered.body, ide_plugin::ViewBody::Message(_)));
|
||||
f.run("grouped-views", "reveal", Some(view)).unwrap();
|
||||
assert!(f.take_events().iter().any(|(client, event)| matches!(
|
||||
event,
|
||||
PluginEvent::RevealView { plugin_id, view: target }
|
||||
if client.as_deref() == Some("main") && plugin_id == "grouped-views" && target == view
|
||||
)));
|
||||
}
|
||||
assert!(f.run("grouped-views", "reveal", Some("plugin.hello.greetings")).is_err());
|
||||
f.host.set_enabled("grouped-views", false).unwrap();
|
||||
assert!(f.host.ui_items("views").is_empty());
|
||||
assert!(f.host.context_keys(Some("main")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_conditions_and_group_ids_are_validated() {
|
||||
for (field, message) in [
|
||||
("group = \"\"", "invalid view group"),
|
||||
("group = \"../other\"", "invalid view group"),
|
||||
("when = \"ready && (\"", "when"),
|
||||
] {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(tmp.path(), |m| {
|
||||
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
|
||||
.replace("location = \"right\"", &format!("location = \"right\"\n{field}"))
|
||||
});
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains(message), "{field}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample plugin makes a tool window with a tree view.
|
||||
/// Its menus, shortcut and status bar item are declarations, listed before
|
||||
/// any code runs; the tree is rendered by the code, which loads on first show.
|
||||
@@ -2811,6 +3016,81 @@ path = "templates/new.draw"
|
||||
assert!(f.host.web_page("web-demo", "draw").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_templates_fixture_installs_without_write_capabilities() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("file-templates"));
|
||||
assert!(info.capabilities.is_empty());
|
||||
let templates = f.host.ui_items("fileTemplates");
|
||||
assert_eq!(templates.len(), 4);
|
||||
let template = |id: &str| templates.iter().find(|t| t.decl["id"] == id).unwrap();
|
||||
assert_eq!(template("dockerignore").decl["fileName"], ".dockerignore");
|
||||
assert_eq!(template("attributes").decl["variables"].as_array().unwrap().len(), 2);
|
||||
assert!(template("repository").decl["body"].as_str().unwrap().contains("$PACKAGE$"));
|
||||
assert!(f.installed("file-templates").join("templates/repository.txt").is_file());
|
||||
assert_eq!(f.run("file-templates", "ping", None).unwrap().as_deref(), Some("File templates ready"));
|
||||
f.host.set_enabled("file-templates", false).unwrap();
|
||||
assert!(f.host.ui_items("fileTemplates").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_templates_api_13_names_and_variables() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("template-demo");
|
||||
std::fs::create_dir_all(&pkg).unwrap();
|
||||
std::fs::write(pkg.join("body.txt"), "$ENTITY$ $PACKAGE$\n").unwrap();
|
||||
let install = |api: &str, fields: &str| {
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"id = "template-demo"
|
||||
name = "Templates"
|
||||
version = "1.0.0"
|
||||
engines.openidea = "{api}"
|
||||
[[contributes.fileTemplates]]
|
||||
id = "template"
|
||||
name = "Template"
|
||||
path = "body.txt"
|
||||
{fields}
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
f.host.install_from_dir(&pkg, &Consent::all())
|
||||
};
|
||||
for fields in [r#"fileName = ".dockerignore""#, "extension = \"java\"\nvariables = []"] {
|
||||
let err = install("^1.2", fields).unwrap_err().message;
|
||||
assert!(err.contains("1.3"), "{err}");
|
||||
}
|
||||
for name in ["", ".", "..", "../escape", "a/b", r"a\b", "/absolute", "C:evil", "a?", " a", "a.", "NUL.txt", "COM1"]
|
||||
{
|
||||
let fields = format!("fileName = {}", serde_json::to_string(name).unwrap());
|
||||
assert!(install("^1.3", &fields).is_err(), "accepted {name:?}");
|
||||
}
|
||||
for vars in [
|
||||
r#"[{ name = "", prompt = "Entity" }]"#,
|
||||
r#"[{ name = "1bad", prompt = "Entity" }]"#,
|
||||
r#"[{ name = "bad-name", prompt = "Entity" }]"#,
|
||||
r#"[{ name = "PACKAGE", prompt = "Entity" }]"#,
|
||||
r#"[{ name = "ENTITY", prompt = "" }]"#,
|
||||
r#"[{ name = "ENTITY", prompt = "Entity" }, { name = "ENTITY", prompt = "Again" }]"#,
|
||||
] {
|
||||
assert!(install("^1.3", &format!("extension = \"java\"\nvariables = {vars}")).is_err(), "accepted {vars}");
|
||||
}
|
||||
install(
|
||||
"^1.3",
|
||||
r#"fileName = ".dockerignore"
|
||||
variables = [{ name = "ENTITY", prompt = "Entity class" }]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = f.host.ui_items("fileTemplates");
|
||||
assert_eq!(items[0].decl["fileName"], ".dockerignore");
|
||||
assert_eq!(items[0].decl["extension"], "");
|
||||
assert_eq!(items[0].decl["variables"][0]["prompt"], "Entity class");
|
||||
assert_eq!(items[0].decl["body"], "$ENTITY$ $PACKAGE$\n");
|
||||
}
|
||||
|
||||
/// A view needs a page or code to render it; a page's directory can't be the package itself.
|
||||
#[test]
|
||||
fn web_view_declarations_are_checked() {
|
||||
@@ -3126,10 +3406,10 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
let actions = f.host.provide_code_actions(&doc("lazy"), ide_core::Range::default());
|
||||
let (plugin, action) = actions.into_iter().next().expect("offered");
|
||||
assert!(action.edits.is_empty() && action.data.as_deref() == Some("fix"));
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone()).unwrap();
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone(), f.inv()).unwrap();
|
||||
assert_eq!(resolved.edits[0].1[0].new_text, "fixed ");
|
||||
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
|
||||
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
|
||||
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign, f.inv()).is_err());
|
||||
|
||||
// File operations come from the `file-actions` export, next to the providers' own.
|
||||
let mut actions = f.host.provide_code_actions(&doc("files"), ide_core::Range::default()).into_iter();
|
||||
@@ -3148,7 +3428,7 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
assert_eq!(action.open, Some(dir.join("created.txt")));
|
||||
// Listed with `data` only: `resolve` on the same export computes its operations.
|
||||
assert!(later.file_action && later.operations.is_empty() && later.data.is_some());
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later).unwrap();
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later, f.inv()).unwrap();
|
||||
assert_eq!(
|
||||
resolved.operations,
|
||||
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
|
||||
@@ -3168,6 +3448,185 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
|
||||
}
|
||||
|
||||
fn interactive_actions_fixture() -> (Fixture, ide_plugin::ProviderDocument) {
|
||||
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
|
||||
let pkg = f._tmp.path().join("interactive-actions");
|
||||
copy_dir(&misbehave_pkg(), &pkg);
|
||||
let manifest = std::fs::read_to_string(pkg.join("plugin.toml"))
|
||||
.unwrap()
|
||||
.replace("required = [\"project.read\"]", "required = [\"project.read\", \"clipboard\"]");
|
||||
std::fs::write(pkg.join("plugin.toml"), format!(
|
||||
"{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"
|
||||
)).unwrap();
|
||||
f.install(&pkg);
|
||||
f.host.run_command("misbehave", "echo", None, f.inv()).unwrap();
|
||||
let doc = ide_plugin::ProviderDocument {
|
||||
path: f.project.join("a.txt"),
|
||||
language: "text".into(),
|
||||
version: 1,
|
||||
text: "prompts".into(),
|
||||
};
|
||||
(f, doc)
|
||||
}
|
||||
|
||||
fn action_prompt(f: &Fixture) -> (String, ide_plugin::Prompt) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some((client, id, prompt)) = f.take_events().into_iter().find_map(|(client, event)| match event {
|
||||
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
|
||||
_ => None,
|
||||
}) {
|
||||
assert_eq!(client.as_deref(), Some("editor-window"));
|
||||
return (id, prompt);
|
||||
}
|
||||
assert!(Instant::now() < deadline, "code-action resolution never asked");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn action_invocation() -> Invocation {
|
||||
Invocation { client: Some("editor-window".into()), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_action_resolution_prompts_in_the_editor_window_under_the_command_budget() {
|
||||
use ide_plugin::Prompt;
|
||||
let (f, doc) = interactive_actions_fixture();
|
||||
let actions = f.host.provide_code_actions(&doc, ide_core::Range::default());
|
||||
assert_eq!(actions.len(), 10);
|
||||
for (kind, answer, expected) in [
|
||||
("pick", vec!["b"], "b"),
|
||||
("pick-many", vec!["a", "b"], "a,b"),
|
||||
("input", vec!["Service😀"], "Service😀"),
|
||||
("confirm", vec![], "confirmed"),
|
||||
] {
|
||||
let action = actions.iter().find(|(_, a)| a.data.as_deref() == Some(kind)).unwrap().1.clone();
|
||||
std::thread::scope(|scope| {
|
||||
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
|
||||
let (id, prompt) = action_prompt(&f);
|
||||
assert!(match kind {
|
||||
"pick" => matches!(prompt, Prompt::QuickPick { multiple: false, .. }),
|
||||
"pick-many" => matches!(prompt, Prompt::QuickPick { multiple: true, .. }),
|
||||
"input" => matches!(prompt, Prompt::Input { multiline: false, .. }),
|
||||
_ => matches!(prompt, Prompt::Confirm { .. }),
|
||||
});
|
||||
// More than the command budget; waiting for the user is excluded.
|
||||
std::thread::sleep(Duration::from_millis(650));
|
||||
assert!(f.host.answer_prompt(&id, Some(answer.iter().map(|s| (*s).into()).collect())));
|
||||
let resolved = running.join().unwrap().unwrap();
|
||||
assert_eq!(resolved.edits[0].1[0].new_text, expected);
|
||||
});
|
||||
}
|
||||
for kind in ["input-multiline", "reveal", "open-url", "clipboard", "pick-directory", "open-project"] {
|
||||
let action = actions.iter().find(|(_, a)| a.data.as_deref() == Some(kind)).unwrap().1.clone();
|
||||
let err = std::thread::scope(|scope| {
|
||||
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
|
||||
let mut prompted = false;
|
||||
while !running.is_finished() {
|
||||
for (_, event) in f.take_events() {
|
||||
if let PluginEvent::PromptRequested { prompt_id, .. } = event {
|
||||
prompted = true;
|
||||
f.host.answer_prompt(&prompt_id, None);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let result = running.join().unwrap();
|
||||
assert!(!prompted, "{kind} must remain restricted to commands and events");
|
||||
result.unwrap_err()
|
||||
});
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput, "{kind}: {}", err.message);
|
||||
}
|
||||
let doc = ide_plugin::ProviderDocument { text: "prompt-provider".into(), ..doc };
|
||||
assert!(eventually(|| !f.host.provide_completion(&doc, ide_core::Pos::new(0, 0), None).is_empty()));
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_action_prompts_dismiss_cancel_and_close_without_edits_or_hanging() {
|
||||
for (how, kind) in [
|
||||
("dismiss", "pick"),
|
||||
("dismiss", "pick-many"),
|
||||
("dismiss", "input"),
|
||||
("dismiss", "confirm"),
|
||||
("disable", "pick"),
|
||||
("close", "pick"),
|
||||
("close-reopen", "pick"),
|
||||
("no-window", "pick"),
|
||||
] {
|
||||
let (f, doc) = interactive_actions_fixture();
|
||||
let action = f
|
||||
.host
|
||||
.provide_code_actions(&doc, ide_core::Range::default())
|
||||
.into_iter()
|
||||
.find(|(_, action)| action.data.as_deref() == Some(kind))
|
||||
.unwrap()
|
||||
.1;
|
||||
if how == "no-window" {
|
||||
assert!(f.host.resolve_code_action("misbehave", &doc, action, Invocation::default()).is_err());
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
continue;
|
||||
}
|
||||
std::thread::scope(|scope| {
|
||||
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
|
||||
let (id, _) = action_prompt(&f);
|
||||
let started = Instant::now();
|
||||
match how {
|
||||
"dismiss" => {
|
||||
assert!(f.host.answer_prompt(&id, None));
|
||||
}
|
||||
"disable" => {
|
||||
f.host.set_enabled("misbehave", false).unwrap();
|
||||
}
|
||||
"close" => f.host.forget_client("editor-window"),
|
||||
"close-reopen" => {
|
||||
f.host.forget_client("editor-window");
|
||||
f.host.reopen_client("editor-window");
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(running.join().unwrap().is_err());
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
assert!(!f.host.answer_prompt(&id, Some(vec!["late".into()])));
|
||||
});
|
||||
assert!(!f.project.join("a.txt").exists(), "resolution must only return edits");
|
||||
if how == "close-reopen" {
|
||||
let action = f.host.provide_code_actions(&doc, ide_core::Range::default()).remove(0).1;
|
||||
std::thread::scope(|scope| {
|
||||
let running =
|
||||
scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
|
||||
let (id, _) = action_prompt(&f);
|
||||
assert!(f.host.answer_prompt(&id, Some(vec!["b".into()])));
|
||||
assert_eq!(running.join().unwrap().unwrap().edits[0].1[0].new_text, "b");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_action_resolution_can_ask_before_returning_resource_operations() {
|
||||
let (f, doc) = interactive_actions_fixture();
|
||||
let doc = ide_plugin::ProviderDocument { text: "prompt-files".into(), ..doc };
|
||||
let action = f.host.provide_code_actions(&doc, ide_core::Range::default()).remove(0).1;
|
||||
assert!(action.file_action && action.operations.is_empty());
|
||||
std::thread::scope(|scope| {
|
||||
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
|
||||
let (id, _) = action_prompt(&f);
|
||||
assert!(f.host.answer_prompt(&id, Some(vec!["Service".into()])));
|
||||
let resolved = running.join().unwrap().unwrap();
|
||||
assert_eq!(
|
||||
resolved.operations,
|
||||
vec![ide_plugin::FileOperation::Create {
|
||||
path: f.project.join("Service.txt"),
|
||||
text: "Service".into(),
|
||||
overwrite: false,
|
||||
}]
|
||||
);
|
||||
assert_eq!(resolved.open, Some(f.project.join("Service.txt")));
|
||||
assert!(!f.project.join("Service.txt").exists(), "resolution does not apply the edit");
|
||||
});
|
||||
}
|
||||
|
||||
/// `hello` as a services view, which needs API 1.3.
|
||||
fn services_variant(text: &str) -> String {
|
||||
text.replace("location = \"right\"", "location = \"services\"")
|
||||
@@ -3226,7 +3685,80 @@ fn services_views_have_a_detail_pane() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("hello"));
|
||||
let err = f.host.service_detail("hello", "greetings", "x", f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("declares no services view"), "a right view has no pane: {err:?}");
|
||||
assert!(err.message.contains("declares no detail view"), "a right view has no pane: {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_views_work_in_every_location_and_require_api_1_3_code() {
|
||||
for location in ["left", "right", "bottom", "services"] {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(tmp.path(), |t| {
|
||||
t.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
|
||||
.replace("location = \"right\"", &format!("location = {location:?}\ndetail = true"))
|
||||
});
|
||||
assert!(f.install(&pkg).error.is_none());
|
||||
assert_eq!(f.host.ui_items("views")[0].decl["detail"], true);
|
||||
let tabs = f.host.service_detail("hello", "greetings", "item:😀", f.inv()).unwrap();
|
||||
assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: item:😀".into()));
|
||||
assert!(f.host.service_detail("hello", "missing", "x", f.inv()).is_err());
|
||||
}
|
||||
for (api, web, message) in
|
||||
[("^1.0", false, "new in plugin API 1.3"), ("^1.3", true, "`detail` needs a tree or list")]
|
||||
{
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(tmp.path(), |t| {
|
||||
t.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = {api:?}")).replace(
|
||||
"location = \"right\"",
|
||||
if web {
|
||||
"location = \"right\"\ndetail = true\nweb = \"web/index.html\""
|
||||
} else {
|
||||
"location = \"right\"\ndetail = true"
|
||||
},
|
||||
)
|
||||
});
|
||||
if web {
|
||||
std::fs::create_dir_all(pkg.join("web")).unwrap();
|
||||
std::fs::write(pkg.join("web/index.html"), "<p>x</p>").unwrap();
|
||||
}
|
||||
let error = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(error.message.contains(message), "{error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_fixture_tracks_selection_and_refresh_for_tree_list_and_services() {
|
||||
let f = fixture(Limits::default());
|
||||
assert!(f.install(&sample("detail-views")).error.is_none());
|
||||
for view in ["tree", "list", "services"] {
|
||||
assert!(matches!(
|
||||
f.host.render_view("test.detail-views", view, None, f.inv()).unwrap().body,
|
||||
ide_plugin::ViewBody::Tree(_) | ide_plugin::ViewBody::List(_)
|
||||
));
|
||||
f.host
|
||||
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Select { key: "beta".into() }, f.inv())
|
||||
.unwrap();
|
||||
let tabs = f.host.service_detail("test.detail-views", view, "beta", f.inv()).unwrap();
|
||||
let ide_plugin::ServicePane::Text(text) = &tabs[0].body else { panic!("text detail") };
|
||||
assert!(text.starts_with(&format!("{view}: beta")));
|
||||
f.host
|
||||
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Button { id: "refresh".into() }, f.inv())
|
||||
.unwrap();
|
||||
let refreshed = f.host.service_detail("test.detail-views", view, "beta", f.inv()).unwrap();
|
||||
assert_ne!(tabs, refreshed);
|
||||
}
|
||||
let selections = [("tree", "alpha"), ("list", "beta"), ("services", "alpha")];
|
||||
for (view, key) in selections {
|
||||
f.host
|
||||
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Select { key: key.into() }, f.inv())
|
||||
.unwrap();
|
||||
}
|
||||
for (view, key) in selections {
|
||||
let tabs = f.host.service_detail("test.detail-views", view, key, f.inv()).unwrap();
|
||||
let ide_plugin::ServicePane::Text(text) = &tabs[0].body else { panic!("text detail") };
|
||||
assert!(text.starts_with(&format!("{view}: {key}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// `hello` as a services view that may run `sh`, granted or not.
|
||||
@@ -3308,6 +3840,30 @@ fn service_logs_need_the_grant_and_a_log_tab() {
|
||||
assert!(!marker.exists(), "a cancelled request starts nothing");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ordinary_detail_views_reuse_log_grants_and_streaming() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(tmp.path(), |t| {
|
||||
services_variant(t).replace("location = \"services\"", "location = \"right\"\ndetail = true").replace(
|
||||
"required = [\"editor.read\", \"editor.write\"]",
|
||||
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]",
|
||||
)
|
||||
});
|
||||
f.install(&pkg);
|
||||
f.host.set_capability("hello", "process:sh", false).unwrap();
|
||||
assert_eq!(
|
||||
stream(&f, "echo allowed", &[], &Default::default(), |_| {}).unwrap_err().kind,
|
||||
ErrorKind::PermissionDenied
|
||||
);
|
||||
f.host.set_capability("hello", "process:sh", true).unwrap();
|
||||
f.host.set_program_path(Arc::new(|| Some(PathBuf::from("/bin").into_os_string())));
|
||||
let lines = Mutex::new(Vec::new());
|
||||
assert_eq!(stream(&f, "echo allowed", &[], &Default::default(), |l| lines.lock().push(l.text)).unwrap(), Some(0));
|
||||
assert_eq!(lines.into_inner(), ["allowed"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn service_log_cancel_kills_the_child() {
|
||||
@@ -3863,3 +4419,342 @@ fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_pro
|
||||
f.host.set_capability("test.path-events", "events", false).unwrap();
|
||||
assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.take_events();
|
||||
let two_mib = "x".repeat(2 << 20);
|
||||
f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Message { text: two_mib.clone() }, f.inv()).unwrap();
|
||||
let echoed = f
|
||||
.take_events()
|
||||
.into_iter()
|
||||
.find_map(|(_, e)| match e {
|
||||
PluginEvent::ViewMessage { view, message, .. } if view == "jobs" => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.expect("2 MiB view message round-trips");
|
||||
assert_eq!(echoed.len(), 2 << 20);
|
||||
|
||||
let too_large = "x".repeat((8 << 20) + 1);
|
||||
let err =
|
||||
f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Message { text: too_large }, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("limited to"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_log_tabs_use_the_web_pty_permission_boundary() {
|
||||
use ide_host::plugins::web::WebPtyCommand;
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.host.install_from_dir(&sample("web-log"), &Consent::default()).unwrap();
|
||||
assert!(info.capabilities.iter().any(|c| c.id == "process:bash" && !c.granted));
|
||||
assert_eq!(f.run("web-log", "ping", None).unwrap().as_deref(), Some("Web log ready"));
|
||||
for command in ["open", "denied"] {
|
||||
f.take_events();
|
||||
assert_eq!(f.run("web-log", command, None).unwrap(), None);
|
||||
assert!(
|
||||
f.take_events()
|
||||
.iter()
|
||||
.any(|(_, event)| matches!(event, PluginEvent::ViewMessage { plugin_id, view, message }
|
||||
if plugin_id == "web-log" && view == "logs" && message == command))
|
||||
);
|
||||
}
|
||||
let command = |program: &str| WebPtyCommand {
|
||||
program: program.into(),
|
||||
args: vec!["--noprofile".into(), "--norc".into()],
|
||||
env: Default::default(),
|
||||
cols: 240,
|
||||
rows: 40,
|
||||
};
|
||||
for program in ["bash", "sh", "/bin/bash"] {
|
||||
assert_eq!(
|
||||
f.host.web_pty_options("web-log", "logs", "main", command(program)).unwrap_err().kind,
|
||||
ErrorKind::PermissionDenied
|
||||
);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
f.host.set_capability("web-log", "process:bash", true).unwrap();
|
||||
let options = f.host.web_pty_options("web-log", "logs", "main", command("bash")).unwrap();
|
||||
assert_eq!(options.shell.as_deref(), f.host.program_for("web-log", "bash").unwrap().to_str());
|
||||
assert_eq!(options.cwd.0, f.project);
|
||||
assert!(options.env.is_empty());
|
||||
assert!(!options.shell_integration);
|
||||
assert!(f.host.web_pty_options("web-log", "logs", "main", command("sh")).is_err());
|
||||
f.host.set_capability("web-log", "process:bash", false).unwrap();
|
||||
assert!(f.host.web_pty_options("web-log", "logs", "main", command("bash")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_ptys_require_a_current_exact_program_grant_and_project() {
|
||||
use ide_host::plugins::web::WebPtyCommand;
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.host.install_from_dir(&sample("web-pty"), &Consent::default()).unwrap();
|
||||
assert!(info.capabilities.iter().any(|c| c.id == "process:git" && !c.granted));
|
||||
let command = || WebPtyCommand {
|
||||
program: "git".into(),
|
||||
args: vec!["--version".into()],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
};
|
||||
let options = |cmd| f.host.web_pty_options("web-pty", "terminal", "main", cmd);
|
||||
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
f.host.set_capability("web-pty", "process:git", true).unwrap();
|
||||
let approved = f.host.program_for("web-pty", "git").unwrap();
|
||||
let got = options(command()).unwrap();
|
||||
assert_eq!(got.shell.as_deref(), approved.to_str());
|
||||
assert!(approved.is_absolute());
|
||||
assert_eq!(got.cwd.0, f.project);
|
||||
assert!(!got.shell_integration);
|
||||
assert_eq!(f.run("web-pty", "ping", None).unwrap().as_deref(), Some("Web PTY ready"));
|
||||
for program in ["sh", "bash", "git-other", "./git", "/tmp/git"] {
|
||||
let cmd = WebPtyCommand { program: program.into(), ..command() };
|
||||
assert!(options(cmd).is_err(), "{program}");
|
||||
}
|
||||
for cmd in [
|
||||
WebPtyCommand { cols: 0, ..command() },
|
||||
WebPtyCommand { rows: 501, ..command() },
|
||||
WebPtyCommand { args: vec!["x".repeat(4097)], ..command() },
|
||||
WebPtyCommand { args: vec!["x".into(); 257], ..command() },
|
||||
WebPtyCommand { args: vec!["\0".into()], ..command() },
|
||||
WebPtyCommand { env: [("PATH".into(), "/tmp".into())].into(), ..command() },
|
||||
WebPtyCommand { env: [("a=b".into(), "x".into())].into(), ..command() },
|
||||
WebPtyCommand { env: [("a".into(), "x".repeat(4097))].into(), ..command() },
|
||||
] {
|
||||
assert!(options(cmd).is_err());
|
||||
}
|
||||
let got = options(WebPtyCommand { env: [("OPENIDEA_WEB_PTY_TEST_NEW".into(), "yes".into())].into(), ..command() })
|
||||
.unwrap();
|
||||
assert_eq!(got.env["OPENIDEA_WEB_PTY_TEST_NEW"], "yes");
|
||||
assert!(f.host.web_pty_options("web-pty", "missing", "main", command()).is_err());
|
||||
f.host.set_workspace(None);
|
||||
assert!(options(command()).is_err());
|
||||
let mut untrusted = TestWorkspace::new(&f.project);
|
||||
Arc::get_mut(&mut untrusted).unwrap().trusted = false;
|
||||
f.host.set_workspace(Some(untrusted));
|
||||
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
f.host.set_workspace(Some(f.workspace.clone()));
|
||||
std::fs::rename(&f.project, f.project.with_extension("moved")).unwrap();
|
||||
assert!(options(command()).unwrap_err().message.contains("no longer exists"));
|
||||
std::fs::rename(f.project.with_extension("moved"), &f.project).unwrap();
|
||||
f.host.set_capability("web-pty", "process:git", false).unwrap();
|
||||
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
f.host.set_enabled("web-pty", false).unwrap();
|
||||
assert!(options(command()).is_err());
|
||||
f.host.uninstall("web-pty").unwrap();
|
||||
assert!(options(command()).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn web_ptys_keep_the_approved_file_and_require_api_1_3() {
|
||||
use ide_host::plugins::web::WebPtyCommand;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = fixture(Limits::default());
|
||||
let bin = f.plugins_dir.parent().unwrap().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let executable = bin.join("git");
|
||||
std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = bin.clone();
|
||||
f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string())));
|
||||
f.install(&sample("web-pty"));
|
||||
let command = || WebPtyCommand { program: "git".into(), args: vec![], env: Default::default(), cols: 80, rows: 24 };
|
||||
f.host.set_program_path(Arc::new(|| Some("/unapproved-path".into())));
|
||||
assert_eq!(
|
||||
f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap().shell.unwrap(),
|
||||
executable.canonicalize().unwrap().to_string_lossy()
|
||||
);
|
||||
std::fs::write(&executable, "#!/bin/sh\nexit 42\n").unwrap();
|
||||
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap_err().message.contains("changed"));
|
||||
std::fs::remove_file(&executable).unwrap();
|
||||
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).is_err());
|
||||
|
||||
let old = tempfile::tempdir().unwrap();
|
||||
let pkg = old.path().join("old");
|
||||
copy_dir(&sample("web-pty"), &pkg);
|
||||
let manifest = pkg.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap().replace("^1.3", "^1.2");
|
||||
std::fs::write(manifest, text).unwrap();
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&pkg);
|
||||
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap_err().message.contains("1.3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_ptys_are_capped_across_windows_and_end_with_the_plugin() {
|
||||
use ide_host::{Host, HostConfig, plugins::web::WebPtyCommand};
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("web-pty"));
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: root.path().join("config"),
|
||||
data_dir: root.path().join("data"),
|
||||
local_data_dir: root.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let spawn = |window: &str| {
|
||||
f.host.spawn_web_pty(
|
||||
&host,
|
||||
"web-pty",
|
||||
"terminal",
|
||||
window,
|
||||
WebPtyCommand {
|
||||
program: "git".into(),
|
||||
args: vec!["hash-object".into(), "--stdin".into()],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
},
|
||||
Arc::new(|_| {}),
|
||||
)
|
||||
};
|
||||
*f.workspace.project_queries_left.lock() = Some(1);
|
||||
assert!(spawn("main").is_err(), "project closed while spawning");
|
||||
assert!(host.terminals.list().is_empty());
|
||||
*f.workspace.project_queries_left.lock() = None;
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..8 {
|
||||
ids.push(spawn(&format!("window-{i}")).unwrap().id);
|
||||
}
|
||||
assert!(spawn("ninth-window").unwrap_err().message.contains("8 PTYs"));
|
||||
host.terminals.kill(ids[0]).unwrap();
|
||||
let replacement = spawn("another-window").unwrap();
|
||||
f.host.reload("web-pty").unwrap();
|
||||
assert!(!host.terminals.contains(replacement.id));
|
||||
assert!(ids.into_iter().all(|id| !host.terminals.contains(id)));
|
||||
let id = spawn("main").unwrap().id;
|
||||
f.host.set_capability("web-pty", "process:git", false).unwrap();
|
||||
assert!(!host.terminals.contains(id));
|
||||
assert!(spawn("main").is_err());
|
||||
f.host.set_capability("web-pty", "process:git", true).unwrap();
|
||||
let id = spawn("main").unwrap().id;
|
||||
f.host.set_enabled("web-pty", false).unwrap();
|
||||
assert!(!host.terminals.contains(id));
|
||||
f.host.set_enabled("web-pty", true).unwrap();
|
||||
let id = spawn("main").unwrap().id;
|
||||
f.host.forget_client("main");
|
||||
assert!(!host.terminals.contains(id));
|
||||
assert!(spawn("main").is_err());
|
||||
f.host.reopen_client("main");
|
||||
let id = spawn("main").unwrap().id;
|
||||
f.host.uninstall("web-pty").unwrap();
|
||||
assert!(!host.terminals.contains(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_icons_ship_sanitized_light_and_dark_assets() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(pkg.join("icons")).unwrap();
|
||||
let manifest = |engine: &str, icon: &str, dark: &str| {
|
||||
format!(
|
||||
r#"
|
||||
id = "marker-icons"
|
||||
name = "Marker Icons"
|
||||
version = "1.0.0"
|
||||
engines.openidea = "{engine}"
|
||||
[[contributes.markerIcons]]
|
||||
id = "resource"
|
||||
icon = "{icon}"
|
||||
iconDark = "{dark}"
|
||||
"#
|
||||
)
|
||||
};
|
||||
let clean = r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#;
|
||||
std::fs::write(pkg.join("icons/resource.svg"), clean).unwrap();
|
||||
std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap();
|
||||
for (engine, icon, dark) in [
|
||||
("^1.0", "icons/resource.svg", "icons/resource_dark.svg"),
|
||||
("^1.3", "../outside.svg", "icons/resource_dark.svg"),
|
||||
("^1.3", "icons/missing.svg", "icons/resource_dark.svg"),
|
||||
("^1.3", "icons/resource.svg", "icons/missing.svg"),
|
||||
] {
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest(engine, icon, dark)).unwrap();
|
||||
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err());
|
||||
}
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest("^1.3", "icons/resource.svg", "icons/resource_dark.svg")).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let outside = tmp.path().join("outside.svg");
|
||||
std::fs::write(&outside, clean).unwrap();
|
||||
std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap();
|
||||
std::os::unix::fs::symlink(outside, pkg.join("icons/resource_dark.svg")).unwrap();
|
||||
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err());
|
||||
std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap();
|
||||
}
|
||||
let mut unsafe_svgs = vec![
|
||||
"<svg><script>alert(1)</script></svg>".to_owned(),
|
||||
"<svg onload=\"alert(1)\"/>".to_owned(),
|
||||
"<svg><foreignObject/></svg>".to_owned(),
|
||||
"<svg><rect style=\"fill:url(https://evil/x)\"/></svg>".to_owned(),
|
||||
"<svg><rect style=\"background-image:image-set('https://evil/x' 1x)\"/></svg>".to_owned(),
|
||||
format!("<svg>{}</svg>", " ".repeat(64 << 10)),
|
||||
];
|
||||
for attr in ["href", "xlink:href"] {
|
||||
for scheme in ["http://evil/x", "data:image/svg+xml,x", "javascript:alert(1)"] {
|
||||
unsafe_svgs.push(format!("<svg><use {attr}=\"{scheme}\"/></svg>"));
|
||||
}
|
||||
}
|
||||
for path in ["icons/resource.svg", "icons/resource_dark.svg"] {
|
||||
for svg in &unsafe_svgs {
|
||||
std::fs::write(pkg.join(path), svg).unwrap();
|
||||
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "accepted {path}: {svg}");
|
||||
}
|
||||
std::fs::write(pkg.join(path), clean).unwrap();
|
||||
}
|
||||
std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap();
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let assets = f.host.assets("marker-icons", "markerIcon/resource/").unwrap();
|
||||
assert_eq!(assets.len(), 2);
|
||||
assert!(assets.iter().any(|(id, svg)| id.ends_with("/light") && svg.contains("<rect")));
|
||||
assert!(assets.iter().any(|(id, svg)| id.ends_with("/dark") && svg.contains("<circle")));
|
||||
let revision = f.host.ui_items("markerIcons")[0].decl["revision"].clone();
|
||||
std::fs::write(pkg.join("icons/resource.svg"), clean.replace("width=\"16\"", "width=\"14\"")).unwrap();
|
||||
let updated = f.install(&pkg);
|
||||
assert!(updated.error.is_none(), "{:?}", updated.error);
|
||||
assert_ne!(f.host.ui_items("markerIcons")[0].decl["revision"], revision);
|
||||
let assets = f.host.assets("marker-icons", "markerIcon/resource/").unwrap();
|
||||
assert!(assets.iter().any(|(id, svg)| id.ends_with("/light") && svg.contains("width=\"14\"")));
|
||||
std::fs::remove_dir_all(pkg).unwrap();
|
||||
assert_eq!(f.host.assets("marker-icons", "markerIcon/resource/").unwrap(), assets);
|
||||
f.host.set_enabled("marker-icons", false).unwrap();
|
||||
assert!(f.host.assets("marker-icons", "markerIcon/resource/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_providers_preserve_names_and_package_paths() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("plugin");
|
||||
copy_dir(&misbehave_pkg(), &pkg);
|
||||
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"line-markers\"]\nlanguages = [\"text\"]\n"),
|
||||
)
|
||||
.unwrap();
|
||||
f.install(&pkg);
|
||||
let doc = ide_plugin::ProviderDocument {
|
||||
path: f.project.join("markers.txt"),
|
||||
language: "text".into(),
|
||||
version: 1,
|
||||
text: "marker-icons\nordinary\ncustom\nlegacy".into(),
|
||||
};
|
||||
assert!(eventually(|| f.host.provide_line_markers(&doc).len() == 4));
|
||||
let markers = f.host.provide_line_markers(&doc);
|
||||
assert_eq!(
|
||||
markers.iter().map(|(_, m)| m.icon.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"]
|
||||
);
|
||||
}
|
||||
@@ -211,6 +211,24 @@ fn persists_user_decisions_locations_revocation_and_one_time_migration() {
|
||||
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inherited_folder_is_trusted_for_this_process_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
|
||||
let root = tmp.path().join("workspaces/app");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(!trust.trusted(&root).unwrap());
|
||||
trust.inherit(&root).unwrap();
|
||||
assert!(trust.trusted(&root).unwrap());
|
||||
assert!(!trust.trusted(root.parent().unwrap()).unwrap(), "only that folder");
|
||||
assert!(trust.status(&root).unwrap().decided, "the trust prompt doesn't ask again");
|
||||
assert!(trust.decisions().unwrap().projects.is_empty(), "no decision is recorded");
|
||||
assert!(!TrustStore::load(&config, &data).trusted(&root).unwrap(), "not persisted");
|
||||
trust.decide(&root, false, false).unwrap();
|
||||
assert!(!trust.trusted(&root).unwrap(), "a decision to restrict it still wins");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn script(root: &Path, name: &str, marker: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -268,6 +268,43 @@ impl ProjectIndex {
|
||||
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
|
||||
}
|
||||
|
||||
/// Scoped symbol search with cooperative interruption, including lock waits.
|
||||
/// `None` means interrupted; no partial results are returned.
|
||||
pub fn find_symbols_until(
|
||||
&self,
|
||||
query: &str,
|
||||
filter: SymbolFilter,
|
||||
limit: usize,
|
||||
include: &(impl Fn(&Path) -> bool + Sync),
|
||||
stopped: &(impl Fn() -> bool + Sync),
|
||||
) -> Option<Vec<SymbolMatch>> {
|
||||
let recent = loop {
|
||||
if stopped() {
|
||||
return None;
|
||||
}
|
||||
if let Some(recent) = self.shared.recent.try_lock_for(Duration::from_millis(1)) {
|
||||
break recent.clone();
|
||||
}
|
||||
};
|
||||
let data = loop {
|
||||
if stopped() {
|
||||
return None;
|
||||
}
|
||||
if let Some(data) = self.shared.data.try_read_for(Duration::from_millis(1)) {
|
||||
break data;
|
||||
}
|
||||
};
|
||||
matching::find_symbols_until(
|
||||
&data,
|
||||
&matching::Recents { paths: &recent },
|
||||
query,
|
||||
filter,
|
||||
limit,
|
||||
include,
|
||||
stopped,
|
||||
)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
|
||||
pub fn symbols_named(&self, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
|
||||
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
|
||||
|
||||
@@ -121,22 +121,35 @@ fn scan(
|
||||
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
|
||||
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
|
||||
) -> Vec<Cand> {
|
||||
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn scan_until(
|
||||
data: &IndexData,
|
||||
pool: usize,
|
||||
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
|
||||
stopped: &(impl Fn() -> bool + Sync),
|
||||
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
|
||||
) -> Option<Vec<Cand>> {
|
||||
data.files
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.with_min_len(256)
|
||||
.fold(Vec::new, |mut acc, (slot, e)| {
|
||||
.try_fold(Vec::new, |mut acc, (slot, e)| {
|
||||
if stopped() {
|
||||
return None;
|
||||
}
|
||||
if let Some(e) = e {
|
||||
per_file(slot as u32, e, &mut acc);
|
||||
if acc.len() >= pool * 2 {
|
||||
acc = keep_best(acc, pool, tiebreak);
|
||||
}
|
||||
}
|
||||
acc
|
||||
Some(acc)
|
||||
})
|
||||
.reduce(Vec::new, |mut a, b| {
|
||||
.try_reduce(Vec::new, |mut a, b| {
|
||||
a.extend(b);
|
||||
if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a }
|
||||
Some(if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -423,9 +436,21 @@ pub(crate) fn find_symbols(
|
||||
filter: SymbolFilter,
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
find_symbols_until(data, recents, query, filter, limit, &|_| true, &|| false).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn find_symbols_until(
|
||||
data: &IndexData,
|
||||
recents: &Recents,
|
||||
query: &str,
|
||||
filter: SymbolFilter,
|
||||
limit: usize,
|
||||
include: &(impl Fn(&Path) -> bool + Sync),
|
||||
stopped: &(impl Fn() -> bool + Sync),
|
||||
) -> Option<Vec<SymbolMatch>> {
|
||||
let q: String = query.trim().chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if q.is_empty() || limit == 0 {
|
||||
return Vec::new();
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let (container_q, name_q) = split_symbol_query(&q);
|
||||
let name = Needle::new(name_q);
|
||||
@@ -436,13 +461,16 @@ pub(crate) fn find_symbols(
|
||||
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
|
||||
};
|
||||
let pool = limit * 4 + 64;
|
||||
let cands = scan(data, pool, &tiebreak, |slot, e, acc| {
|
||||
if e.symbols.is_empty() {
|
||||
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
|
||||
if e.symbols.is_empty() || !include(&e.path) {
|
||||
return;
|
||||
}
|
||||
with_matcher(|m| {
|
||||
let mut buf = Vec::new();
|
||||
for (i, s) in e.symbols.iter().enumerate() {
|
||||
if stopped() {
|
||||
break;
|
||||
}
|
||||
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
|
||||
continue;
|
||||
}
|
||||
@@ -451,11 +479,15 @@ pub(crate) fn find_symbols(
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})?;
|
||||
if stopped() {
|
||||
return None;
|
||||
}
|
||||
let mut out: Vec<(u32, Cand, Vec<u32>)> = with_matcher(|m| {
|
||||
let mut buf = Vec::new();
|
||||
cands
|
||||
.into_iter()
|
||||
.take_while(|_| !stopped())
|
||||
.filter_map(|c| {
|
||||
let s = sym(&c);
|
||||
let e = file(data, c.slot);
|
||||
@@ -472,7 +504,8 @@ pub(crate) fn find_symbols(
|
||||
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
|
||||
});
|
||||
out.truncate(limit);
|
||||
out.into_iter()
|
||||
let results = out
|
||||
.into_iter()
|
||||
.map(|(score, c, indices)| {
|
||||
let e = file(data, c.slot);
|
||||
let s = &e.symbols[c.sym as usize];
|
||||
@@ -487,7 +520,8 @@ pub(crate) fn find_symbols(
|
||||
indices,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(!stopped()).then_some(results)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
|
||||
|
||||
@@ -652,3 +652,80 @@ fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
|
||||
assert!(symbols.iter().any(|s| s.name == "find"));
|
||||
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_search_can_stop_during_scanning_and_lock_waits() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
let fx = sample();
|
||||
let idx = fx.open();
|
||||
let calls = AtomicUsize::new(0);
|
||||
let stopped = || calls.fetch_add(1, Ordering::Relaxed) >= 5;
|
||||
assert!(idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &stopped).is_none());
|
||||
assert!(calls.load(Ordering::Relaxed) > 5);
|
||||
for recent in [false, true] {
|
||||
let _data = (!recent).then(|| idx.shared.data.write());
|
||||
let _recent = recent.then(|| idx.shared.recent.lock());
|
||||
let deadline = std::time::Instant::now() + Duration::from_millis(10);
|
||||
assert!(
|
||||
idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &|| std::time::Instant::now() >= deadline)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_symbol_search_preserves_fuzzy_ranking_within_allowed_paths() {
|
||||
let fx = sample();
|
||||
let idx = fx.open();
|
||||
let allowed = fx.root.join("src/order");
|
||||
let symbols =
|
||||
idx.find_symbols_until("OCo", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false).unwrap();
|
||||
assert_eq!(symbols.len(), 1);
|
||||
assert_eq!(symbols[0].name, "OrderController");
|
||||
assert!(
|
||||
idx.find_symbols_until("OtherCollection", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn incremental_symbols_resolve_symlink_ancestors_before_scope_checks() {
|
||||
let fx = Fixture::new(&[("src/inside.rs", "fn inside_symbol() {}\n")]);
|
||||
let outside = fx._dir.path().join("outside");
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
std::fs::write(outside.join("secret.rs"), "fn secret_symbol() {}\n").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, fx.root.join("escape")).unwrap();
|
||||
std::os::unix::fs::symlink(fx.root.join("src"), fx.root.join("alias")).unwrap();
|
||||
let idx = fx.open();
|
||||
idx.update(&[fx.root.join("escape/secret.rs"), fx.root.join("alias/inside.rs")], &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(symbols(&idx, "secret_symbol", SymbolFilter::All).is_empty());
|
||||
let inside = idx.find_symbols("inside_symbol", SymbolFilter::All, 10);
|
||||
assert_eq!(inside.len(), 1);
|
||||
assert_eq!(inside[0].path.0, fx.root.join("src/inside.rs"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
|
||||
let fx = Fixture::new(&[("src/old.rs", "fn old_symbol() {}\n")]);
|
||||
drop(fx.open());
|
||||
let outside = fx._dir.path().join("outside");
|
||||
std::fs::rename(fx.root.join("src"), &outside).unwrap();
|
||||
std::os::unix::fs::symlink(outside, fx.root.join("src")).unwrap();
|
||||
let initial = Arc::new(Mutex::new(Vec::new()));
|
||||
let events = initial.clone();
|
||||
let sink: SharedSink = Arc::new(move |event: IdeEvent| {
|
||||
if let IdeEvent::IndexState { symbols, .. } = event {
|
||||
events.lock().push(symbols);
|
||||
}
|
||||
});
|
||||
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
|
||||
let idx = ProjectIndex::open(&fx.root, &fx.cache, Vec::new(), Vec::new(), languages, Arc::new(Syntax::new()), sink)
|
||||
.unwrap();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(symbols(&idx, "old_symbol", SymbolFilter::All).is_empty());
|
||||
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
|
||||
}
|
||||
@@ -174,7 +174,10 @@ impl Shared {
|
||||
Ok(records) => {
|
||||
let entries: Vec<FileEntry> = records
|
||||
.into_par_iter()
|
||||
.filter(|(rel, _)| !filter.is_excluded(&root.join(rel), false))
|
||||
.filter(|(rel, _)| {
|
||||
let path = root.join(rel);
|
||||
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
|
||||
})
|
||||
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
|
||||
.collect();
|
||||
let mut d = self.data.write();
|
||||
@@ -420,7 +423,10 @@ impl Shared {
|
||||
let mut seen = HashSet::new();
|
||||
let mut by_dir: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
|
||||
for path in changed {
|
||||
if !seen.insert(path.clone()) {
|
||||
// A watcher path may have a symlink ancestor even when its spelling
|
||||
// is inside the root. Resolve it on the worker before reading it.
|
||||
let Ok(path) = dunce::canonicalize(&path) else { continue };
|
||||
if !filter.contains(&path) || !seen.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
|
||||
|
||||
@@ -22,8 +22,49 @@ impl Syntax {
|
||||
if language.as_str() == "properties" {
|
||||
return Ok(properties(text));
|
||||
}
|
||||
if language.as_str() == "xml" {
|
||||
// Only Spring wiring that mentions property placeholders can declare
|
||||
// a configuration key; skip parsing unrelated XML (POMs, mappers,
|
||||
// manifests) during project scans.
|
||||
if !text.contains("<prop") && !text.contains("placeholder") && !text.contains("Placeholder") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return self.with_parsed(language, text, cancelled, |tree| tree.spring_xml_keys(cancelled));
|
||||
}
|
||||
self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled))
|
||||
}
|
||||
|
||||
/// Name of the Spring `${name[:default]}` placeholder covering `pos` in
|
||||
/// `text`, if the position lies anywhere inside its braces. The name stops
|
||||
/// at the first `:` or `}`; inside a nested default (`${a:${b}}`) the inner
|
||||
/// braces name `b`. Empty names (`${}`) name nothing.
|
||||
pub fn configuration_placeholder(&self, text: &str, pos: Pos) -> Option<String> {
|
||||
let at = byte_at(text, pos)?;
|
||||
let mut braces = Vec::new();
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for (i, byte) in text.bytes().enumerate() {
|
||||
match byte {
|
||||
b'{' => braces.push((i, None, i > 0 && text.as_bytes()[i - 1] == b'$')),
|
||||
b':' => {
|
||||
if let Some((_, end, true)) = braces.last_mut() {
|
||||
end.get_or_insert(i);
|
||||
}
|
||||
}
|
||||
b'}' => {
|
||||
if let Some((open, end, true)) = braces.pop() {
|
||||
let open = open - 1;
|
||||
if open <= at && at <= i && best.is_none_or(|(previous, _)| open > previous) {
|
||||
best = Some((open, end.unwrap_or(i)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (open, end) = best?;
|
||||
let name = text[open + 2..end].trim();
|
||||
(!name.is_empty()).then(|| name.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Spring's property lookups whose first argument names a configuration key.
|
||||
@@ -68,6 +109,134 @@ impl ParsedTree<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Spring property declarations in XML wiring: `<prop key="name">` values
|
||||
/// (util:properties, configurer `<props>`) and `<property name="name"
|
||||
/// value="...">` entries under a placeholder configurer bean. Elements that
|
||||
/// only reference a property file (`<context:property-placeholder>`,
|
||||
/// `<util:properties location="...">`) declare no key of their own; the
|
||||
/// configuration scan finds the keys inside the referenced files.
|
||||
pub fn spring_xml_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
|
||||
let mut out = Vec::new();
|
||||
spring_xml(self.tree.root_node(), self.text, &LineIndex::new(self.text), &mut out, cancelled, 0, false);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Bean classes whose `<property name="key" value="...">` entries feed
|
||||
/// placeholder resolution (the placeholder configurers).
|
||||
fn placeholder_bean(class: &str) -> bool {
|
||||
class.to_ascii_lowercase().contains("placeholder")
|
||||
}
|
||||
|
||||
fn local_name(tag: &str) -> &str {
|
||||
tag.rsplit(':').next().unwrap_or(tag)
|
||||
}
|
||||
|
||||
fn spring_xml(
|
||||
node: Node<'_>,
|
||||
text: &str,
|
||||
lines: &LineIndex<'_>,
|
||||
out: &mut Vec<ConfigurationKey>,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
depth: usize,
|
||||
configurer: bool,
|
||||
) {
|
||||
if depth > 64 || cancelled() || out.len() >= 20_000 {
|
||||
return;
|
||||
}
|
||||
if node.kind() != "element" {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
spring_xml(child, text, lines, out, cancelled, depth + 1, configurer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut cursor = node.walk();
|
||||
let children: Vec<_> = node.named_children(&mut cursor).collect();
|
||||
let mut tag = "";
|
||||
let mut inside = configurer;
|
||||
let mut content: Vec<Node<'_>> = Vec::new();
|
||||
for child in children {
|
||||
match child.kind() {
|
||||
"STag" | "EmptyElemTag" => {
|
||||
let mut attrs_cursor = child.walk();
|
||||
let attrs: Vec<_> = child.named_children(&mut attrs_cursor).collect();
|
||||
let mut attributes: Vec<(&str, String, Range)> = Vec::new();
|
||||
for (i, attr) in attrs.iter().enumerate() {
|
||||
if i == 0 && attr.kind() == "Name" {
|
||||
tag = local_name(&text[attr.byte_range()]);
|
||||
continue;
|
||||
}
|
||||
if attr.kind() != "Attribute" {
|
||||
continue;
|
||||
}
|
||||
let mut parts_cursor = attr.walk();
|
||||
let mut name = "";
|
||||
let mut value = None;
|
||||
for part in attr.named_children(&mut parts_cursor) {
|
||||
match part.kind() {
|
||||
"Name" => name = &text[part.byte_range()],
|
||||
"AttValue" => value = Some(quoted_value(part, text, lines)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some((value, range)) = value {
|
||||
attributes.push((name, value, range));
|
||||
}
|
||||
}
|
||||
let attribute = |wanted: &str| attributes.iter().find(|(name, _, _)| *name == wanted);
|
||||
if tag == "prop"
|
||||
&& let Some((_, key, range)) = attribute("key")
|
||||
&& !key.is_empty()
|
||||
&& key.len() <= 1024
|
||||
{
|
||||
out.push(ConfigurationKey { name: key.clone(), range: *range });
|
||||
}
|
||||
if tag == "property"
|
||||
&& configurer
|
||||
&& let (Some((_, name, range)), Some(_)) = (attribute("name"), attribute("value"))
|
||||
&& !name.is_empty()
|
||||
&& name.len() <= 1024
|
||||
{
|
||||
out.push(ConfigurationKey { name: name.clone(), range: *range });
|
||||
}
|
||||
if tag == "bean"
|
||||
&& let Some((_, class, _)) = attribute("class")
|
||||
{
|
||||
inside = placeholder_bean(class);
|
||||
}
|
||||
}
|
||||
"content" => content.extend(child.named_children(&mut cursor).filter(|n| n.kind() == "element")),
|
||||
// Error recovery can nest elements without a `content` node.
|
||||
"element" => content.push(child),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for element in content {
|
||||
spring_xml(element, text, lines, out, cancelled, depth + 1, inside);
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoded text of an `AttValue` and the range of its quoted contents.
|
||||
fn quoted_value(node: Node<'_>, text: &str, lines: &LineIndex<'_>) -> (String, Range) {
|
||||
let raw = &text[node.byte_range()];
|
||||
let quoted = raw.len() >= 2
|
||||
&& ((raw.starts_with('"') && raw.ends_with('"')) || (raw.starts_with('\'') && raw.ends_with('\'')));
|
||||
let (value, range) = if quoted {
|
||||
(&raw[1..raw.len() - 1], lines.range(node.start_byte() + 1, node.end_byte() - 1))
|
||||
} else {
|
||||
(raw, lines.range(node.start_byte(), node.end_byte()))
|
||||
};
|
||||
(unescape_xml(value), range)
|
||||
}
|
||||
|
||||
/// XML's five predefined entities; unknown references keep their text.
|
||||
fn unescape_xml(value: &str) -> String {
|
||||
if !value.contains('&') {
|
||||
return value.to_owned();
|
||||
}
|
||||
value.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
fn yaml(
|
||||
@@ -317,4 +486,107 @@ class A {
|
||||
);
|
||||
assert_eq!(keys[0].range.end.line, 2);
|
||||
}
|
||||
#[test]
|
||||
fn placeholder_names_cover_the_whole_reference() {
|
||||
let text = "name: ${db.url:8080}\nplain: value\nempty: ${}\nnested: ${a:${b.c}}\nspaced: ${ db.h }\n";
|
||||
let syntax = Syntax::new();
|
||||
let at = |needle: &str, offset: u32| {
|
||||
let line = text.lines().position(|l| l.contains(needle)).unwrap();
|
||||
let line_text = text.lines().nth(line).unwrap();
|
||||
let byte = line_text.find(needle).unwrap();
|
||||
let col = line_text[..byte].encode_utf16().count() as u32 + offset;
|
||||
syntax.configuration_placeholder(text, Pos::new(line as u32, col))
|
||||
};
|
||||
// On the `$`, inside the name and on the default all name `db.url`.
|
||||
assert_eq!(at("${db.url", 0), Some("db.url".into()));
|
||||
assert_eq!(at("db.url", 1), Some("db.url".into()));
|
||||
assert_eq!(at("8080", 1), Some("db.url".into()));
|
||||
assert_eq!(at("plain", 1), None);
|
||||
assert_eq!(at("empty", 3), None);
|
||||
// A nested default names its own property; the outer braces name `a`.
|
||||
assert_eq!(at("${b.c", 2), Some("b.c".into()));
|
||||
assert_eq!(at("${a:", 2), Some("a".into()));
|
||||
assert_eq!(at("db.h", 1), Some("db.h".into()));
|
||||
assert_eq!(syntax.configuration_placeholder("no placeholder", Pos::new(0, 3)), None);
|
||||
assert_eq!(syntax.configuration_placeholder("trailing ${open", Pos::new(0, 12)), None);
|
||||
}
|
||||
#[test]
|
||||
fn nested_placeholder_defaults_are_brace_balanced() {
|
||||
let syntax = Syntax::new();
|
||||
let text = "${outer:${inner}suffix}";
|
||||
for col in 0..text.len() {
|
||||
let expected = if (8..=15).contains(&col) { "inner" } else { "outer" };
|
||||
assert_eq!(
|
||||
syntax.configuration_placeholder(text, Pos::new(0, col as u32)).as_deref(),
|
||||
Some(expected),
|
||||
"column {col}"
|
||||
);
|
||||
}
|
||||
let text = "${outer:${middle:${inner}}suffix}";
|
||||
for (needle, expected) in [("outer", "outer"), ("middle", "middle"), ("inner", "inner"), ("suffix", "outer")] {
|
||||
assert_eq!(
|
||||
syntax.configuration_placeholder(text, Pos::new(0, text.find(needle).unwrap() as u32)).as_deref(),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
syntax.configuration_placeholder("${outer:{default}suffix}", Pos::new(0, 19)).as_deref(),
|
||||
Some("outer")
|
||||
);
|
||||
assert_eq!(syntax.configuration_placeholder("${outer:${inner}suffix", Pos::new(0, 18)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_placeholders_use_utf16_columns() {
|
||||
let syntax = Syntax::new();
|
||||
for text in ["message=你好😀 ${db.user}", "message: 你好😀 ${db.user}", "<bean value=\"你好😀 ${db.user}\"/>"]
|
||||
{
|
||||
let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32;
|
||||
assert_eq!(syntax.configuration_placeholder(text, Pos::new(0, col + 2)).as_deref(), Some("db.user"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_xml_declares_prop_and_configurer_property_keys() {
|
||||
let text = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<beans xmlns="http://www.springframework.org/schema/beans">
|
||||
<context:property-placeholder location="classpath:db.properties"/>
|
||||
<util:properties id="db">
|
||||
<prop key="db.url">jdbc:h2:mem:test</prop>
|
||||
</util:properties>
|
||||
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
|
||||
<property name="properties">
|
||||
<props>
|
||||
<prop key="db.user">sa</prop>
|
||||
</props>
|
||||
</property>
|
||||
</bean>
|
||||
<bean class="org.springframework.jdbc.datasource.DriverManagerDataSource">
|
||||
<property name="url" value="jdbc:h2:mem"/>
|
||||
</bean>
|
||||
<bean class="org.springframework.beans.factory.config.PropertySourcesPlaceholderConfigurer">
|
||||
<property name="system.mode" value="test"/>
|
||||
</bean>
|
||||
</beans>
|
||||
"#;
|
||||
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
|
||||
let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
|
||||
// The configurer's own `properties` injection names no property, and a
|
||||
// plain bean's `<property name="url">` is not an environment property.
|
||||
assert_eq!(names, ["db.url", "db.user", "system.mode"]);
|
||||
// The range covers the quoted key attribute's value.
|
||||
let line = text.lines().position(|l| l.contains("db.url")).unwrap() as u32;
|
||||
let start = keys[0].range.start;
|
||||
assert_eq!((start.line, keys[0].range.end.line), (line, line));
|
||||
assert_eq!(
|
||||
&text.lines().nth(line as usize).unwrap()[start.col as usize..keys[0].range.end.col as usize],
|
||||
"db.url"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn xml_without_property_markers_is_not_parsed() {
|
||||
let text = "<mappers><mapper resource='a.xml'/><select id='x'>select 1</select></mappers>";
|
||||
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,16 @@ const MAX_CACHED_KEYS: usize = 100_000;
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ConfigurationCache {
|
||||
files: HashMap<PathBuf, CachedFile>,
|
||||
roots: HashMap<PathBuf, CachedPaths>,
|
||||
}
|
||||
struct CachedPaths {
|
||||
paths: Vec<PathBuf>,
|
||||
incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
impl ConfigurationCache {
|
||||
pub(crate) fn invalidate_paths(&mut self) {
|
||||
self.roots.clear();
|
||||
}
|
||||
}
|
||||
struct CachedFile {
|
||||
stamp: (SystemTime, u64),
|
||||
@@ -49,6 +59,9 @@ fn language(path: &Path) -> Option<LanguageId> {
|
||||
match path.extension()?.to_str()? {
|
||||
"yaml" | "yml" => Some(LanguageId::new("yaml")),
|
||||
"properties" => Some(LanguageId::new("properties")),
|
||||
// Spring XML wiring declares properties in `<prop key="...">` values
|
||||
// and placeholder-configurer `<property name="...">` entries.
|
||||
"xml" => Some(LanguageId::new("xml")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -158,6 +171,16 @@ impl LspManager {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A click inside a Spring `${name}` placeholder sits on a value, not a
|
||||
// key: the name names the property whose declaration lives in
|
||||
// configuration files or Spring XML, with Java binding members still
|
||||
// answering through the Java index.
|
||||
let placeholder = if config && key.is_none() && !usages && snap.text.as_str().len() as u64 <= MAX_BYTES {
|
||||
syntax().configuration_placeholder(snap.text.as_str(), pos)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let key = placeholder.clone().or(key);
|
||||
if config && key.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -178,7 +201,8 @@ impl LspManager {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await;
|
||||
let result =
|
||||
self.configuration_query(snap, pos, key, placeholder.is_some(), usages, include_declaration, cancel).await;
|
||||
match result {
|
||||
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()),
|
||||
Err(e) => {
|
||||
@@ -189,22 +213,63 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn configuration_query(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
pos: Pos,
|
||||
key: Option<String>,
|
||||
placeholder: bool,
|
||||
usages: bool,
|
||||
include_declaration: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<References>> {
|
||||
let (server, run) = self.jdtls(&snap.path, cancel).await?;
|
||||
// A `${name}` reference resolves from configuration files and Spring
|
||||
// XML without Java; those declarations are collected first so
|
||||
// navigation survives an unavailable or outdated Java extension.
|
||||
let local = if placeholder {
|
||||
let name = key.clone().unwrap_or_default();
|
||||
let path = snap.path.clone();
|
||||
let project_root = self.project_root.clone();
|
||||
let root = tokio::task::spawn_blocking(move || {
|
||||
path.parent()
|
||||
.into_iter()
|
||||
.flat_map(Path::ancestors)
|
||||
.take_while(|p| p.starts_with(&project_root))
|
||||
.find(|p| {
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
|
||||
.iter()
|
||||
.any(|name| p.join(name).is_file())
|
||||
})
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or(project_root)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
|
||||
Some(
|
||||
self.configuration_declarations(
|
||||
vec![Pattern { name, relaxed: true }],
|
||||
std::slice::from_ref(&root),
|
||||
cancel,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let answered = local.as_ref().is_some_and(|local| !local.locations.is_empty());
|
||||
let (server, run) = match self.jdtls(&snap.path, cancel).await {
|
||||
Ok(found) => found,
|
||||
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()),
|
||||
Err(_) if answered => return Ok(local),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if !run.caps.read().commands().iter().any(|command| command == COMMAND) {
|
||||
return Ok(None);
|
||||
return Ok(if placeholder { local } else { None });
|
||||
}
|
||||
let target = Target { server: server.clone(), run };
|
||||
let position = snap.text.ide_to_lsp(Encoding::Utf16, pos);
|
||||
let response: Response = self
|
||||
let response: Response = match self
|
||||
.call(
|
||||
&target,
|
||||
"workspace/executeCommand",
|
||||
@@ -214,11 +279,17 @@ impl LspManager {
|
||||
Some(cancel),
|
||||
crate::LONG_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()),
|
||||
Err(_) if answered => return Ok(local),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if response.keys.is_empty() {
|
||||
if response.keys.is_empty() && !placeholder {
|
||||
return Ok(response
|
||||
.incomplete
|
||||
.then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() }));
|
||||
@@ -229,22 +300,18 @@ impl LspManager {
|
||||
..Default::default()
|
||||
};
|
||||
self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref()));
|
||||
if key.is_none() || usages {
|
||||
if let Some(local) = local {
|
||||
result.locations.extend(local.locations);
|
||||
result.incomplete = result.incomplete.or(local.incomplete);
|
||||
}
|
||||
if key.is_none() || usages || placeholder {
|
||||
let roots: Vec<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
|
||||
let overlays: HashMap<PathBuf, String> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
|
||||
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
|
||||
.collect();
|
||||
let cache = self.configuration_cache.clone();
|
||||
let cancelled = cancel.clone();
|
||||
let keys = response.keys;
|
||||
let files =
|
||||
tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled))
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
|
||||
let patterns = if placeholder {
|
||||
vec![Pattern { name: key.clone().unwrap_or_default(), relaxed: true }]
|
||||
} else {
|
||||
response.keys
|
||||
};
|
||||
let files = self.configuration_declarations(patterns, &roots, cancel).await?;
|
||||
result.locations.extend(files.locations);
|
||||
result.incomplete = result.incomplete.or(files.incomplete);
|
||||
}
|
||||
@@ -266,6 +333,29 @@ impl LspManager {
|
||||
crate::convert::dedup_locations(&mut result.locations);
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
/// Configuration-file and Spring-XML declarations matching `patterns` under
|
||||
/// `roots`, with open editor contents taking precedence over disk.
|
||||
async fn configuration_declarations(
|
||||
&self,
|
||||
patterns: Vec<Pattern>,
|
||||
roots: &[PathBuf],
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<References> {
|
||||
let overlays: HashMap<PathBuf, String> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
|
||||
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
|
||||
.collect();
|
||||
let cache = self.configuration_cache.clone();
|
||||
let cancelled = cancel.clone();
|
||||
let roots = roots.to_vec();
|
||||
tokio::task::spawn_blocking(move || configuration_files(&roots, &patterns, &overlays, &cache, &cancelled))
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))
|
||||
}
|
||||
}
|
||||
|
||||
fn in_module(root: &Path, path: &Path) -> bool {
|
||||
@@ -296,11 +386,15 @@ fn configuration_files(
|
||||
) -> References {
|
||||
let mut result = References::default();
|
||||
let mut paths = std::collections::BTreeSet::new();
|
||||
// Do not scan a source root twice when its enclosing module is also present.
|
||||
let roots: Vec<_> =
|
||||
roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect();
|
||||
for root in &roots {
|
||||
let module = (*root).clone();
|
||||
let mut cache = cache.lock();
|
||||
for root in roots {
|
||||
if let Some(cached) = cache.roots.get(root) {
|
||||
paths.extend(cached.paths.iter().cloned());
|
||||
result.incomplete = result.incomplete.or(cached.incomplete);
|
||||
continue;
|
||||
}
|
||||
let mut found = CachedPaths { paths: Vec::new(), incomplete: None };
|
||||
let module = root.clone();
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.follow_links(false)
|
||||
.filter_entry(move |entry| {
|
||||
@@ -324,21 +418,26 @@ fn configuration_files(
|
||||
if cancel.is_cancelled() {
|
||||
return result;
|
||||
}
|
||||
if visited >= 50_000 || paths.len() >= MAX_FILES {
|
||||
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
|
||||
if visited >= 50_000 || found.paths.len() >= MAX_FILES {
|
||||
found.incomplete = Some(ReferencesIncomplete::TooManyFiles);
|
||||
break;
|
||||
}
|
||||
match entry {
|
||||
Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => {
|
||||
paths.insert(entry.into_path());
|
||||
found.paths.push(entry.into_path());
|
||||
}
|
||||
Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
|
||||
Err(_) => found.incomplete = Some(ReferencesIncomplete::FilesSkipped),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
paths.extend(found.paths.iter().cloned());
|
||||
result.incomplete = result.incomplete.or(found.incomplete);
|
||||
if cache.roots.len() >= MAX_FILES {
|
||||
cache.roots.clear();
|
||||
}
|
||||
cache.roots.insert(root.clone(), found);
|
||||
}
|
||||
paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned());
|
||||
let mut cache = cache.lock();
|
||||
if cache.files.len() > MAX_FILES {
|
||||
cache.files.clear();
|
||||
}
|
||||
|
||||
@@ -2314,6 +2314,9 @@ impl LspManager {
|
||||
|
||||
/// Tells running servers that files or folders moved (`moves`: from → to, after the move).
|
||||
pub fn did_rename_files(&self, moves: &[(PathBuf, PathBuf)]) {
|
||||
if !moves.is_empty() {
|
||||
self.configuration_cache.lock().invalidate_paths();
|
||||
}
|
||||
let moves: Vec<(PathBuf, PathBuf, bool)> =
|
||||
moves.iter().map(|(f, t)| (f.clone(), t.clone(), t.is_dir())).collect();
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
@@ -2334,6 +2337,13 @@ impl LspManager {
|
||||
/// into `.venv` or a `go.mod` rewritten by `go get` is picked up without a restart. Other
|
||||
/// servers hear of their languages' files under their root; without it jdtls's incremental
|
||||
/// build never sees them, so a launch runs stale classes.
|
||||
///
|
||||
/// Creates and changes for documents synced on a server's current instance are left out.
|
||||
/// Their text reaches the server through `didOpen`/`didChange`, including clean-buffer
|
||||
/// reloads. A server that re-reads the file on the watched change (csharp-ls) would apply
|
||||
/// the reload and then the `didChange` computed against the pre-reload copy, so it would
|
||||
/// keep analysing content that diverges from both the disk and the editor (#1780).
|
||||
/// Deletions still reach it: document sync does not describe workspace membership.
|
||||
pub fn watched_files_changed(
|
||||
&self,
|
||||
created: &[PathBuf],
|
||||
@@ -2342,20 +2352,47 @@ impl LspManager {
|
||||
excluded_dirs: &[PathBuf],
|
||||
) {
|
||||
use file_watch::Change;
|
||||
if !created.is_empty() || !removed.is_empty() || !excluded_dirs.is_empty() {
|
||||
self.configuration_cache.lock().invalidate_paths();
|
||||
}
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
let open: HashMap<(ide_core::ServerId, u64), HashSet<PathBuf>> = {
|
||||
let docs = self.docs.lock();
|
||||
let mut open: HashMap<_, HashSet<_>> = HashMap::new();
|
||||
for d in docs.values() {
|
||||
for a in &d.servers {
|
||||
if let Some(generation) = a.opened_gen {
|
||||
open.entry((a.server.id, generation))
|
||||
.or_default()
|
||||
.extend([d.path.clone(), d.canonical.clone()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
open
|
||||
};
|
||||
let canonical: HashMap<_, _> =
|
||||
created.iter().chain(modified).filter_map(|p| dunce::canonicalize(p).ok().map(|c| (p, c))).collect();
|
||||
for s in servers {
|
||||
let Some(run) = s.running() else { continue };
|
||||
let open = open.get(&(s.id, run.generation)).filter(|_| run.caps.read().sync != SyncKind::None);
|
||||
let send = |p: &PathBuf, change: Change| {
|
||||
change == Change::Deleted
|
||||
|| !open
|
||||
.is_some_and(|paths| paths.contains(p) || canonical.get(p).is_some_and(|c| paths.contains(c)))
|
||||
};
|
||||
let all = [(created, Change::Created), (modified, Change::Changed), (removed, Change::Deleted)]
|
||||
.into_iter()
|
||||
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
|
||||
let watchers = run.watchers.read();
|
||||
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
|
||||
let languages = s.languages();
|
||||
all.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
|
||||
.collect()
|
||||
all.filter(|(p, change)| {
|
||||
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
|
||||
.filter(|(p, change)| watchers.wants(&s.root, p, *change))
|
||||
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
|
||||
.collect()
|
||||
};
|
||||
drop(watchers);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! other project files are silently missing. So, as a word index would, the
|
||||
//! project files of the server's languages that contain the symbol's name as a
|
||||
//! whole word (found through the host's [`CandidateFn`]) and that the first
|
||||
//! answer did not cover are opened on the server for a second request, then
|
||||
//! closed again. Diagnostics the server publishes for them are dropped.
|
||||
//! answer and the server's analysis scope did not cover are opened for a second
|
||||
//! request, then closed again. Diagnostics the server publishes for them are dropped.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::text::DocText;
|
||||
@@ -29,6 +29,8 @@ pub const MAX_CANDIDATES: usize = 300;
|
||||
const MAX_CANDIDATE_BYTES: u64 = 1 << 20;
|
||||
/// How long a search that found nothing waits for a server to finish indexing.
|
||||
const INDEX_WAIT: Duration = Duration::from_secs(10);
|
||||
/// Optional server command: candidate URIs in, already analysed candidate URIs out.
|
||||
const COVERED_FILES: &str = "openidea.workspace.coveredFiles";
|
||||
|
||||
/// Project files that may mention a symbol.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -103,7 +105,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Runs `query` on `t`; then again with the project files that mention the
|
||||
/// symbol at `pos` and are not in `covered(first)` opened on the server.
|
||||
/// symbol at `pos` outside `covered(first)` and the server's analysis scope opened.
|
||||
/// A first answer that is empty while the server indexes is retried once
|
||||
/// indexing ends.
|
||||
pub(crate) async fn widened<R, F, Fut>(
|
||||
@@ -146,6 +148,10 @@ impl LspManager {
|
||||
}
|
||||
let known: HashSet<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
|
||||
let files: Vec<PathBuf> = candidates.files.into_iter().filter(|f| !known.contains(f)).collect();
|
||||
let files = self.files_beyond_scope(t, files, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Ok(Widened { first, second: None, incomplete });
|
||||
}
|
||||
@@ -174,6 +180,27 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn files_beyond_scope(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> Vec<PathBuf> {
|
||||
if files.is_empty() || !t.caps().commands().iter().any(|c| c == COVERED_FILES) {
|
||||
return files;
|
||||
}
|
||||
let uris: Vec<String> = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect();
|
||||
let params = json!({ "command": COVERED_FILES, "arguments": [uris] });
|
||||
// An unsupported, failed or malformed scope answer must not remove candidates.
|
||||
let covered: IdeResult<Vec<String>> =
|
||||
self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
|
||||
match covered {
|
||||
Ok(covered) => {
|
||||
let covered: HashSet<_> = covered.into_iter().collect();
|
||||
files.into_iter().zip(uris).filter_map(|(f, uri)| (!covered.contains(&uri)).then_some(f)).collect()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: analysis scope unavailable: {}", t.server.name, e.message);
|
||||
files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
|
||||
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
|
||||
/// also the declarations containing them, as incoming calls.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package demo;
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
class User {
|
||||
private String name;
|
||||
private int age;
|
||||
}
|
||||
|
||||
@lombok.Getter
|
||||
@lombok.Setter
|
||||
@lombok.RequiredArgsConstructor
|
||||
class Profile {
|
||||
private final String id;
|
||||
private String name;
|
||||
}
|
||||
|
||||
@lombok.extern.java.Log
|
||||
class Logged {
|
||||
void write() { log.info("hello"); }
|
||||
}
|
||||
|
||||
class PlainUser {
|
||||
private String name;
|
||||
PlainUser(String name) { this.name = name; }
|
||||
String getName() { return name; }
|
||||
void setName(String name) { this.name = name; }
|
||||
}
|
||||
@@ -422,7 +422,8 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
|
||||
// `a.mk` is open on the server: its changes reach it through document sync, not here.
|
||||
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("changed.mk"), h.root.join("old.mk"));
|
||||
// Other languages and files outside the server's root are left out.
|
||||
let other = h.root.join("notes.txt");
|
||||
let outside = tempfile::tempdir().unwrap().path().join("far.mk");
|
||||
@@ -448,6 +449,197 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
/// An external change to a document open on the server's current instance reaches it only
|
||||
/// through the reload's `didChange`: a watched-file change for it too would make a server
|
||||
/// that re-reads the file on one (csharp-ls) apply the reload and then the diff computed
|
||||
/// against the pre-reload copy, so it would keep analysing stale content (#1780). A closed
|
||||
/// file's change is watched as before, and the file is watched again once its document closes.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn documents_open_on_a_server_are_left_out_of_watched_file_changes() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, path) = h.open("a.mk", "old");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
eventually(|| !s.notified("textDocument/didOpen").is_empty()).await;
|
||||
|
||||
// The open document and a closed file changed outside the editor.
|
||||
let closed = h.root.join("b.mk");
|
||||
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
|
||||
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
|
||||
json!([{ "uri": uri_of(&closed), "type": 2 }])
|
||||
);
|
||||
|
||||
// The editor reloads the open buffer from disk; the server hears the new text.
|
||||
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "new")], &|| "new".to_owned());
|
||||
eventually(|| s.docs.lock().get(uri_of(&path).as_str()).map(String::as_str) == Some("new")).await;
|
||||
|
||||
// Once the document closes, the file is watched again.
|
||||
h.mgr.did_close(doc);
|
||||
h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
|
||||
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
|
||||
json!([{ "uri": uri_of(&path), "type": 2 }])
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
async fn watch_all(s: &MockState) {
|
||||
s.peer()
|
||||
.request::<_, Value>(
|
||||
"client/registerCapability",
|
||||
json!({ "registrations": [{ "id": "all", "method": "workspace/didChangeWatchedFiles",
|
||||
"registerOptions": { "watchers": [{ "globPattern": "**/*" }] } }] }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_document_watches_keep_deletions_and_renames() {
|
||||
for registered in [false, true] {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (_, path) = h.open("a.mk", "old");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
if registered {
|
||||
watch_all(&s).await;
|
||||
}
|
||||
let closed = h.root.join("closed.mk");
|
||||
// An atomic replacement is reported as created and still uses document sync.
|
||||
h.mgr.watched_files_changed(std::slice::from_ref(&path), &[path.clone(), closed.clone()], &[], &[]);
|
||||
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
|
||||
json!([{ "uri": uri_of(&closed), "type": 2 }])
|
||||
);
|
||||
|
||||
let renamed = h.root.join("renamed.mk");
|
||||
std::fs::rename(&path, &renamed).unwrap();
|
||||
h.mgr.watched_files_changed(std::slice::from_ref(&renamed), &[], std::slice::from_ref(&path), &[]);
|
||||
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
|
||||
json!([
|
||||
{ "uri": uri_of(&renamed), "type": 1 },
|
||||
{ "uri": uri_of(&path), "type": 3 }
|
||||
])
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_document_watches_resolve_path_aliases() {
|
||||
for registered in [false, true] {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (_, path) = h.open("Mixed.mk", "old");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
if registered {
|
||||
watch_all(&s).await;
|
||||
}
|
||||
let mut aliases = vec![path.clone()];
|
||||
let case_alias = h.root.join("mixed.mk");
|
||||
if dunce::canonicalize(&case_alias).ok() == Some(path.clone()) {
|
||||
aliases.push(case_alias);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = h.root.join("alias.mk");
|
||||
std::os::unix::fs::symlink(&path, &link).unwrap();
|
||||
aliases.push(link);
|
||||
}
|
||||
let closed = h.root.join("closed.mk");
|
||||
aliases.push(closed.clone());
|
||||
h.mgr.watched_files_changed(&[], &aliases, &[], &[]);
|
||||
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
|
||||
json!([{ "uri": uri_of(&closed), "type": 2 }])
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_document_watches_are_scoped_to_the_attached_server() {
|
||||
let h = harness(&[("mock", incremental_caps()), ("other", incremental_caps())]);
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "mock").cloned().collect()));
|
||||
let (_, path) = h.open("a.mk", "old");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "other").cloned().collect()));
|
||||
h.open("b.mk", "other");
|
||||
h.wait_status("other", ServerStatus::Ready).await;
|
||||
let first = h.launcher.state("mock");
|
||||
let second = h.launcher.state("other");
|
||||
watch_all(&first).await;
|
||||
watch_all(&second).await;
|
||||
let closed = h.root.join("closed.mk");
|
||||
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
|
||||
eventually(|| {
|
||||
!first.notified("workspace/didChangeWatchedFiles").is_empty()
|
||||
&& !second.notified("workspace/didChangeWatchedFiles").is_empty()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
first.notified("workspace/didChangeWatchedFiles")[0]["changes"],
|
||||
json!([{ "uri": uri_of(&closed), "type": 2 }])
|
||||
);
|
||||
assert_eq!(
|
||||
second.notified("workspace/didChangeWatchedFiles")[0]["changes"],
|
||||
json!([
|
||||
{ "uri": uri_of(&path), "type": 2 }, { "uri": uri_of(&closed), "type": 2 }
|
||||
])
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_document_watches_resume_on_a_restart_without_open_documents() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, path) = h.open("a.mk", "old");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let id = h.mgr.servers()[0].id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
let closed = h.root.join("closed.mk");
|
||||
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
|
||||
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
|
||||
json!([{ "uri": uri_of(&closed), "type": 2 }])
|
||||
);
|
||||
h.mgr.did_close(doc);
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
|
||||
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
|
||||
json!([{ "uri": uri_of(&path), "type": 2 }])
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn servers_without_document_sync_keep_open_document_watches() {
|
||||
let h = harness(&[("mock", json!({ "textDocumentSync": 0 }))]);
|
||||
let (_, path) = h.open("a.mk", "old");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let closed = h.root.join("closed.mk");
|
||||
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
|
||||
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
|
||||
json!([{ "uri": uri_of(&path), "type": 2 }, { "uri": uri_of(&closed), "type": 2 }])
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
/// A server that registers file watchers hears of what they match, inside excluded folders
|
||||
/// too (`uv add` into `.venv`, `go get` rewriting `go.mod`); folders outside the project
|
||||
/// that relative patterns name are reported for watching; unregistered, it falls back to
|
||||
@@ -2930,6 +3122,112 @@ fn scope_harness() -> (Harness, Arc<MockState>) {
|
||||
(h, s)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn indexed_sources_are_not_widened_but_outside_callers_are() {
|
||||
for classified in [false, true] {
|
||||
for outside in [false, true] {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"]["commands"] = if classified {
|
||||
json!(["openidea.workspace.coveredFiles", "openidea.java.usages"])
|
||||
} else {
|
||||
json!(["openidea.workspace.coveredFiles"])
|
||||
};
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let caller = h.root.join("Caller.java");
|
||||
let decoy = h.root.join("Unrelated.java");
|
||||
let extra = h.root.join("Outside.java");
|
||||
std::fs::write(&caller, "target();").unwrap();
|
||||
std::fs::write(&decoy, "target").unwrap();
|
||||
if outside {
|
||||
std::fs::write(&extra, "target();").unwrap();
|
||||
}
|
||||
let covered = vec![uri_of(&caller), uri_of(&decoy)];
|
||||
let indexed = uri_of(&caller);
|
||||
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
|
||||
if method == "workspace/executeCommand" && params["command"] == "openidea.workspace.coveredFiles" {
|
||||
return Some(json!(covered));
|
||||
}
|
||||
let model = MockState::default();
|
||||
*model.docs.lock() = s.docs.lock().clone();
|
||||
model.docs.lock().insert(indexed.clone(), "target();".into());
|
||||
let responder = open_files_only(vec![]);
|
||||
if method == "workspace/executeCommand" && params["command"] == "openidea.java.usages" {
|
||||
let refs = responder(&model, "textDocument/references", params).unwrap();
|
||||
return Some(Value::Array(
|
||||
refs.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|location| json!({ "location": location, "kind": "unknown", "access": "read" }))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
responder(&model, method, params)
|
||||
}));
|
||||
grep_candidates(&h);
|
||||
let (doc, _) = h.open("Target.java", "def target\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let refs = h
|
||||
.mgr
|
||||
.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(refs.locations.iter().any(|l| l.path == caller));
|
||||
assert_eq!(refs.locations.iter().any(|l| l.path == extra), outside);
|
||||
assert_eq!(refs.incomplete, None);
|
||||
let calls = h
|
||||
.mgr
|
||||
.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(calls.items.iter().any(|i| i.path == caller));
|
||||
assert_eq!(calls.items.iter().any(|i| i.path == extra), outside);
|
||||
assert_eq!(calls.incomplete, None);
|
||||
let rounds = if outside { 2 } else { 1 };
|
||||
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), rounds);
|
||||
if classified {
|
||||
assert_eq!(
|
||||
s.requested("workspace/executeCommand")
|
||||
.iter()
|
||||
.filter(|p| p["command"] == "openidea.java.usages")
|
||||
.count(),
|
||||
rounds
|
||||
);
|
||||
assert_eq!(refs.classified.len(), refs.locations.len());
|
||||
} else {
|
||||
assert_eq!(s.requested("textDocument/references").len(), rounds);
|
||||
}
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let opened = uris(&s.notified("textDocument/didOpen"));
|
||||
assert!(!opened.contains(&uri_of(&caller)));
|
||||
assert!(!opened.contains(&uri_of(&decoy)));
|
||||
assert_eq!(opened.contains(&uri_of(&extra)), outside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() {
|
||||
for response in [json!(null), json!([false]), json!([])] {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]);
|
||||
let h = harness(&[("mock", caps)]);
|
||||
let s = h.launcher.state("mock");
|
||||
*s.responder.lock() = Some(open_files_only(vec![]));
|
||||
s.respond("workspace/executeCommand", response);
|
||||
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
|
||||
files: vec![q.root.join("b.mk")],
|
||||
limit_hit: true,
|
||||
}));
|
||||
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles));
|
||||
assert_eq!(s.requested("textDocument/references").len(), 2);
|
||||
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn references_open_unopened_files_that_mention_the_name() {
|
||||
let (h, s) = scope_harness();
|
||||
@@ -4195,6 +4493,198 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_placeholder_definition_lands_in_spring_xml_and_properties() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
let resources = h.root.join("src/main/resources");
|
||||
std::fs::create_dir_all(&resources).unwrap();
|
||||
std::fs::write(
|
||||
resources.join("context.xml"),
|
||||
r#"<beans><util:properties id="db"><prop key="db.user">sa</prop></util:properties></beans>"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(h.root.join("db.properties"), "db.user=root\n").unwrap();
|
||||
let config = h.root.join("application.yml");
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
|
||||
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
|
||||
// The Java extension is not needed: the declarations come from the XML
|
||||
// wiring and the properties file, and the open YAML never matches itself.
|
||||
assert_eq!(locations.len(), 2, "{locations:?}");
|
||||
assert!(locations.iter().any(|l| l.path.to_path_buf() == resources.join("context.xml")));
|
||||
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("db.properties")));
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_placeholders_use_java_module_roots_and_utf16_columns() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(
|
||||
h.root.join("pom.xml"),
|
||||
"<project><packaging>pom</packaging><modules><module>child</module></modules></project>",
|
||||
)
|
||||
.unwrap();
|
||||
let child = h.root.join("child");
|
||||
let resources = child.join("src/main/resources");
|
||||
std::fs::create_dir_all(&resources).unwrap();
|
||||
std::fs::write(child.join("pom.xml"), "<project/>").unwrap();
|
||||
let definition = resources.join("application.properties");
|
||||
std::fs::write(&definition, "db.user=sa\n").unwrap();
|
||||
let sibling = h.root.join("sibling");
|
||||
std::fs::create_dir(&sibling).unwrap();
|
||||
std::fs::write(sibling.join("pom.xml"), "<project/>").unwrap();
|
||||
std::fs::write(sibling.join("application.properties"), "db.user=wrong\n").unwrap();
|
||||
h.launcher.state("jdtls").respond("workspace/executeCommand", json!({
|
||||
"locations": [], "keys": [], "roots": [uri_of(&h.root), uri_of(&child), uri_of(&child.join("src/main/java"))]
|
||||
}));
|
||||
for (file, language, text) in [
|
||||
("references.properties", "properties", "message=你好😀 ${db.user}"),
|
||||
("references.yml", "yaml", "message: 你好😀 ${db.user}"),
|
||||
("context.xml", "xml", "<bean value=\"你好😀 ${db.user}\"/>"),
|
||||
] {
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &h.root.join(file), &LanguageId::new(language), 1, text);
|
||||
let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32;
|
||||
let locations = h.mgr.goto(doc, Pos::new(0, col + 2), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(locations.len(), 1, "{file}: {locations:?}");
|
||||
assert_eq!(locations[0].path.to_path_buf(), definition);
|
||||
assert_eq!(locations[0].range.start, Pos::new(0, 0));
|
||||
assert_eq!(locations[0].range.end, Pos::new(0, 7));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_file_list_refreshes_on_watched_changes_and_renames() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
let child = h.root.join("child");
|
||||
std::fs::create_dir(&child).unwrap();
|
||||
std::fs::write(child.join("pom.xml"), "<project/>").unwrap();
|
||||
let file = child.join("application.properties");
|
||||
std::fs::write(&file, "db.user=sa\n").unwrap();
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &child.join("reference.yml"), &LanguageId::new("yaml"), 1, "message: ${db.user}");
|
||||
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
|
||||
let profile = child.join("application-dev.properties");
|
||||
std::fs::write(&profile, "db.user=dev\n").unwrap();
|
||||
// No notification yet: the existing file list is reused.
|
||||
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
|
||||
h.mgr.watched_files_changed(std::slice::from_ref(&profile), &[], &[], &[]);
|
||||
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2);
|
||||
std::fs::write(&profile, "db.user=development\n").unwrap();
|
||||
h.mgr.watched_files_changed(&[], std::slice::from_ref(&profile), &[], &[]);
|
||||
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2);
|
||||
let renamed = child.join("application-test.properties");
|
||||
std::fs::rename(&profile, &renamed).unwrap();
|
||||
h.mgr.did_rename_files(&[(profile, renamed.clone())]);
|
||||
let locations = h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(locations.len(), 2);
|
||||
assert!(locations.iter().any(|l| l.path.to_path_buf() == renamed));
|
||||
std::fs::remove_file(&renamed).unwrap();
|
||||
h.mgr.watched_files_changed(&[], &[], &[renamed], &[]);
|
||||
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_placeholder_definition_merges_java_members_with_xml_declarations() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
let target = uri_of(&h.root.join("Db.java"));
|
||||
s.respond(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}],
|
||||
"keys": [{"name": "db.user", "relaxed": true}], "roots": []
|
||||
}),
|
||||
);
|
||||
let config = h.root.join("application.yml");
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
|
||||
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
|
||||
// A bound Java member and the XML declaration are both candidates.
|
||||
assert_eq!(locations.len(), 2, "{locations:?}");
|
||||
assert!(locations.iter().any(|l| l.range.start == Pos::new(1, 4)));
|
||||
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml")));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_placeholder_definition_survives_a_failing_java_extension() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into()));
|
||||
let config = h.root.join("application.yml");
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
|
||||
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
|
||||
// The XML declaration still answers when the Java extension fails.
|
||||
assert_eq!(locations.len(), 1, "{locations:?}");
|
||||
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml")));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_key_definition_stays_java_only() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="spring.datasource.username">sa</prop></beans>"#)
|
||||
.unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
let target = uri_of(&h.root.join("Settings.java"));
|
||||
s.respond(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}],
|
||||
"keys": [{"name": "spring.datasource.username", "relaxed": true}], "roots": []
|
||||
}),
|
||||
);
|
||||
let config = h.root.join("application.yml");
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: sa\n");
|
||||
let locations = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Definition).await.unwrap();
|
||||
// A key keeps the Java-only definition path; the XML declaration is not a candidate.
|
||||
assert_eq!(locations.len(), 1, "{locations:?}");
|
||||
assert_eq!(locations[0].range.start, Pos::new(2, 4));
|
||||
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "spring.datasource.username");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_definition_on_an_xml_property_asks_java() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
let target = uri_of(&h.root.join("Db.java"));
|
||||
s.respond(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}],
|
||||
"keys": [{"name": "db.user", "relaxed": true}], "roots": []
|
||||
}),
|
||||
);
|
||||
let xml = h.root.join("context.xml");
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &xml, &LanguageId::new("xml"), 1, "<beans><prop key=\"db.user\">sa</prop></beans>\n");
|
||||
let locations = h.mgr.goto(doc, Pos::new(0, 19), GotoKind::Definition).await.unwrap();
|
||||
// A Spring XML declaration is a configuration site: it navigates to the
|
||||
// binding member through the Java index.
|
||||
assert_eq!(locations.len(), 1, "{locations:?}");
|
||||
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "db.user");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
|
||||
let mut caps = incremental_caps();
|
||||
|
||||
@@ -112,6 +112,146 @@ async fn kmp_lsp_java_end_to_end() {
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires jdtls on PATH and a compatible JDK"]
|
||||
async fn java_searches_skip_indexed_sources_but_keep_exclusions_outside_scope() {
|
||||
use ide_lsp::types::HierarchyDirection;
|
||||
use serde_json::json;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
std::fs::create_dir_all(root.join("src/demo/excluded")).unwrap();
|
||||
std::fs::write(root.join(".project"), "<projectDescription><name>search-scope</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>").unwrap();
|
||||
std::fs::write(root.join(".classpath"), "<classpath><classpathentry kind=\"src\" path=\"src\" excluding=\"demo/excluded/**\"/><classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/><classpathentry kind=\"output\" path=\"bin\"/></classpath>").unwrap();
|
||||
let source = "package demo; public class Target { public static void target() {} }";
|
||||
let path = root.join("src/demo/Target.java");
|
||||
let caller = root.join("src/demo/Caller.java");
|
||||
let decoy = root.join("src/demo/Decoy.java");
|
||||
let excluded = root.join("src/demo/excluded/Excluded.java");
|
||||
let outside = root.join("Outside.java");
|
||||
std::fs::write(&path, source).unwrap();
|
||||
std::fs::write(&caller, "package demo; class Caller { void call() { Target.target(); } }").unwrap();
|
||||
// A source file mentioning the name without a usage, too large for transient opening.
|
||||
std::fs::write(&decoy, format!("package demo; class Decoy {{ void target() {{}} }}\n/*{}*/", " ".repeat(1 << 20)))
|
||||
.unwrap();
|
||||
std::fs::write(&excluded, "package demo.excluded; class Excluded {}").unwrap();
|
||||
std::fs::write(&outside, "class Outside {}").unwrap();
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
java.language_servers.retain(|s| s.name == "jdtls");
|
||||
let server = &mut java.language_servers[0];
|
||||
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
|
||||
server.command = bin;
|
||||
}
|
||||
server.args.extend(["-data".into(), dir.path().join("metadata").to_string_lossy().into_owned()]);
|
||||
let bundle = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/java-usages/openidea-java-usages.jar");
|
||||
server
|
||||
.initialization_options
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.as_table_mut()
|
||||
.unwrap()
|
||||
.insert("bundles".into(), toml::Value::Array(vec![toml::Value::String(bundle.to_string_lossy().into_owned())]));
|
||||
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
|
||||
mgr.java_build(&path, &Default::default()).await.unwrap();
|
||||
let uri = ide_lsp::path_to_uri_string;
|
||||
let covered = retry("imported source scope", Duration::from_secs(120), || async {
|
||||
mgr.jdtls_command(
|
||||
&path,
|
||||
"openidea.workspace.coveredFiles",
|
||||
vec![json!([uri(&caller), uri(&decoy), uri(&excluded), uri(&outside),])],
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.filter(|v| v.as_array().is_some_and(|a| !a.is_empty()))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(covered, json!([uri(&caller), uri(&decoy)]));
|
||||
mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: vec![caller.clone(), decoy.clone()],
|
||||
limit_hit: false,
|
||||
}));
|
||||
let at = Pos::new(0, source.find("target()").unwrap() as u32);
|
||||
let refs = mgr.references_streaming(doc, at, false, true, false, Default::default(), None).await.unwrap();
|
||||
assert_eq!(refs.locations.len(), 1);
|
||||
assert!(refs.locations[0].path.ends_with("Caller.java"));
|
||||
assert_eq!(refs.incomplete, None, "the indexed decoy must not be transiently opened");
|
||||
let calls = mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, Default::default()).await.unwrap();
|
||||
assert_eq!(calls.items.len(), 1);
|
||||
assert!(calls.items[0].path.ends_with("Caller.java"));
|
||||
assert_eq!(calls.incomplete, None);
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires jdtls on PATH and a compatible JDK"]
|
||||
async fn lombok_generated_members_appear_in_document_symbols() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let source = include_str!("fixtures/LombokSymbols.java");
|
||||
let path = root.join("src/demo/LombokSymbols.java");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let agent = dunce::canonicalize(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/lombok/lombok-1.18.48-openidea.3.jar"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".project"),
|
||||
"<projectDescription><name>lombok-symbols</name><buildSpec><buildCommand>\
|
||||
<name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures>\
|
||||
<nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>",
|
||||
)
|
||||
.unwrap();
|
||||
let library = agent.to_string_lossy().replace('&', "&").replace('"', """).replace('<', "<");
|
||||
std::fs::write(
|
||||
root.join(".classpath"),
|
||||
format!(
|
||||
"<classpath><classpathentry kind=\"src\" path=\"src\"/>\
|
||||
<classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/>\
|
||||
<classpathentry kind=\"lib\" path=\"{library}\"/>\
|
||||
<classpathentry kind=\"output\" path=\"bin\"/></classpath>"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
java.language_servers.retain(|s| s.name == "jdtls");
|
||||
let server = &mut java.language_servers[0];
|
||||
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
|
||||
server.command = bin;
|
||||
}
|
||||
server.args.extend([
|
||||
"-data".into(),
|
||||
root.join("workspace").to_string_lossy().into_owned(),
|
||||
format!("--jvm-arg=-javaagent:{}", agent.display()),
|
||||
]);
|
||||
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
|
||||
let expected: &[(&str, &[&str])] = &[
|
||||
("User", &["getName()", "setName(String)", "getAge()", "setAge(int)", "User(String, int)", "builder()"]),
|
||||
("UserBuilder", &["name(String)", "age(int)", "build()"]),
|
||||
("Profile", &["Profile(String)", "getId()", "getName()", "setName(String)"]),
|
||||
("Logged", &["log", "write()"]),
|
||||
("PlainUser", &["PlainUser(String)", "getName()", "setName(String)"]),
|
||||
];
|
||||
fn has_members(items: &[ide_core::OutlineItem], owner: &str, names: &[&str]) -> bool {
|
||||
items.iter().any(|item| {
|
||||
(item.name == owner && names.iter().all(|n| item.children.iter().any(|c| c.name == *n)))
|
||||
|| has_members(&item.children, owner, names)
|
||||
})
|
||||
}
|
||||
retry("Lombok document symbols", Duration::from_secs(120), || async {
|
||||
let items = mgr.document_symbols(doc).await.ok().flatten()?;
|
||||
expected.iter().all(|(owner, names)| has_members(&items, owner, names)).then_some(())
|
||||
})
|
||||
.await;
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires jdtls on PATH and a compatible JDK"]
|
||||
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! idle reclaim ends it.
|
||||
|
||||
use crate::runtime::{CallCancel, CallError, Runtime};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::thread::JoinHandle;
|
||||
@@ -20,6 +21,17 @@ use std::time::{Duration, Instant};
|
||||
|
||||
type Job = Box<dyn FnOnce(&mut Option<Runtime>) + Send>;
|
||||
|
||||
fn catch_call<R>(f: impl FnOnce() -> Result<R, CallError>) -> Result<R, CallError> {
|
||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|panic| {
|
||||
let message = panic
|
||||
.downcast_ref::<String>()
|
||||
.map(String::as_str)
|
||||
.or_else(|| panic.downcast_ref::<&str>().copied())
|
||||
.unwrap_or("unknown panic");
|
||||
Err(CallError::Trap(format!("plugin actor panicked: {message}")))
|
||||
})
|
||||
}
|
||||
|
||||
/// Why an [`Actor::call`] produced no result.
|
||||
#[derive(Debug)]
|
||||
pub enum ActorError {
|
||||
@@ -85,15 +97,24 @@ impl Actor {
|
||||
let cancel = runtime.cancel_handle();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let busy = Arc::new(AtomicBool::new(false));
|
||||
let crash = Arc::new(Mutex::new(None));
|
||||
let last_used = Arc::new(AtomicU64::new(0));
|
||||
let epoch = Instant::now();
|
||||
let thread = {
|
||||
let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone());
|
||||
let (alive, busy, last_used, crash) = (alive.clone(), busy.clone(), last_used.clone(), crash.clone());
|
||||
std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || {
|
||||
let mut slot = Some(runtime);
|
||||
while let Ok(job) = rx.recv() {
|
||||
busy.store(true, Ordering::Release);
|
||||
job(&mut slot);
|
||||
// A completion callback may panic too; never leave a dead thread marked alive.
|
||||
if let Err(CallError::Trap(error)) = catch_call(|| {
|
||||
job(&mut slot);
|
||||
Ok(())
|
||||
}) {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(error);
|
||||
alive.store(false, Ordering::Release);
|
||||
slot = None;
|
||||
}
|
||||
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
|
||||
busy.store(false, Ordering::Release);
|
||||
if slot.is_none() {
|
||||
@@ -113,7 +134,7 @@ impl Actor {
|
||||
thread: Mutex::new(Some(thread)),
|
||||
cancel,
|
||||
alive,
|
||||
crash: Arc::default(),
|
||||
crash,
|
||||
busy,
|
||||
last_used,
|
||||
epoch,
|
||||
@@ -148,7 +169,7 @@ impl Actor {
|
||||
return;
|
||||
}
|
||||
let Some(rt) = slot.as_mut() else { return };
|
||||
let outcome = f(rt);
|
||||
let outcome = catch_call(|| f(rt));
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
// Before the reply: the caller sees the actor dead.
|
||||
@@ -174,21 +195,59 @@ impl Actor {
|
||||
cancel: &ide_core::CancellationToken,
|
||||
grace: Duration,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
self.call_cancellable_before(cancel, grace, None, f)
|
||||
}
|
||||
|
||||
/// A cancellable editor call: queued work expires after `soft`, and a
|
||||
/// successful answer produced later is dropped. Running work still has
|
||||
/// its runtime hard deadline and can be cancelled by further typing.
|
||||
pub fn call_edit_cancellable<R: Send + 'static>(
|
||||
&self,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
grace: Duration,
|
||||
soft: Duration,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), f)
|
||||
}
|
||||
|
||||
fn call_cancellable_before<R: Send + 'static>(
|
||||
&self,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
grace: Duration,
|
||||
deadline: Option<Instant>,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
let (reply, result) = mpsc::sync_channel(1);
|
||||
let (crash, alive) = (self.crash.clone(), self.alive.clone());
|
||||
let skip = cancel.clone();
|
||||
let running = Arc::new(Mutex::new(false));
|
||||
let in_job = running.clone();
|
||||
let job: Job = Box::new(move |slot| {
|
||||
if skip.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let Some(rt) = slot.as_mut() else { return };
|
||||
let outcome = f(rt);
|
||||
{
|
||||
let mut running = in_job.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if skip.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if deadline.is_some_and(|t| Instant::now() >= t) {
|
||||
let _ = reply.send(Err(ActorError::SoftTimeout));
|
||||
return;
|
||||
}
|
||||
*running = true;
|
||||
}
|
||||
let outcome = catch_call(|| f(rt));
|
||||
*in_job.lock().unwrap_or_else(|e| e.into_inner()) = false;
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
alive.store(false, Ordering::Release);
|
||||
*slot = None;
|
||||
}
|
||||
let outcome = match outcome {
|
||||
Ok(_) if deadline.is_some_and(|t| Instant::now() > t) => Err(ActorError::SoftTimeout),
|
||||
other => other.map_err(ActorError::Call),
|
||||
};
|
||||
let _ = reply.send(outcome);
|
||||
});
|
||||
{
|
||||
@@ -203,25 +262,34 @@ impl Actor {
|
||||
drop(outcome);
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
Ok(outcome) => return outcome.map_err(ActorError::Call),
|
||||
Ok(outcome) => return outcome,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped });
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if !cancel.is_cancelled() {
|
||||
if deadline.is_some_and(|t| Instant::now() >= t) && !*running.lock().unwrap_or_else(|e| e.into_inner())
|
||||
{
|
||||
return Err(ActorError::SoftTimeout);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match cancelled_at {
|
||||
None => {
|
||||
cancelled_at = Some(Instant::now());
|
||||
if self.is_busy() {
|
||||
self.cancel.cancel_soft();
|
||||
let running = running.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !*running {
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
self.cancel.cancel_soft();
|
||||
}
|
||||
Some(at) if at.elapsed() >= grace => {
|
||||
// Did not return in time: interrupt it (the instance is rebuilt).
|
||||
self.cancel();
|
||||
let running = running.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *running {
|
||||
self.cancel();
|
||||
}
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
Some(_) => {}
|
||||
@@ -239,7 +307,7 @@ impl Actor {
|
||||
let (crash, alive) = (self.crash.clone(), self.alive.clone());
|
||||
let job: Job = Box::new(move |slot| {
|
||||
let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) };
|
||||
let outcome = f(rt);
|
||||
let outcome = catch_call(|| f(rt));
|
||||
if let Err(CallError::Trap(e)) = &outcome {
|
||||
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
|
||||
alive.store(false, Ordering::Release);
|
||||
|
||||
@@ -8,8 +8,9 @@ use crate::types::{
|
||||
PluginHover, PluginInlayHint, ProviderDocument, SemanticToken,
|
||||
};
|
||||
use crate::types::{
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessChunk,
|
||||
ProcessCommand, ProcessOutput, Prompt, RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken,
|
||||
WorkbenchEvent,
|
||||
};
|
||||
use crate::types::{
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
|
||||
@@ -121,6 +122,19 @@ pub trait HostApi: Send + Sync {
|
||||
.filter(|root| real.starts_with(crate::resolve_real(root)))
|
||||
.max_by_key(|root| root.components().count())
|
||||
}
|
||||
|
||||
/// Searches the current in-memory index of an open project.
|
||||
fn symbols(
|
||||
&self,
|
||||
root: &Path,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
cancel: &CancellationToken,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<crate::SymbolInfo>, HostError> {
|
||||
let _ = (root, query, limit, cancel, deadline);
|
||||
Err(HostError::NotFound("no project index is available".into()))
|
||||
}
|
||||
/// Unsaved text of an open editor's file.
|
||||
fn buffer_text(&self, path: &Path) -> Option<String>;
|
||||
/// Version and text of the open document at `path`.
|
||||
@@ -167,11 +181,11 @@ pub trait HostApi: Send + Sync {
|
||||
/// Performs `request` (its host is granted), filling in secret placeholders.
|
||||
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
|
||||
fn secret_is_set(&self, name: &str) -> bool;
|
||||
/// Runs the approved file of `process:<program>`.
|
||||
/// Runs the approved file of `process:<program>` with `cmd.env` added to
|
||||
/// the inherited environment, and waits.
|
||||
fn run_process(
|
||||
&self,
|
||||
program: &str,
|
||||
args: Vec<String>,
|
||||
cmd: ProcessCommand,
|
||||
cwd: Option<&Path>,
|
||||
stdin: Option<String>,
|
||||
cancel: &CancellationToken,
|
||||
@@ -182,6 +196,35 @@ pub trait HostApi: Send + Sync {
|
||||
/// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`.
|
||||
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError>;
|
||||
fn unsubscribe(&self, kind: &str);
|
||||
/// Starts `cmd` and returns the handle to it without waiting.
|
||||
fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result<u64, HostError>;
|
||||
/// What process `id` printed since the last call, each stream at most
|
||||
/// `max` bytes (the rest waits for the next call), and its status once it
|
||||
/// ended.
|
||||
fn poll_process(&self, id: u64, max: usize) -> Result<ProcessChunk, HostError>;
|
||||
/// Writes `text` to the stdin of process `id`.
|
||||
fn write_process(
|
||||
&self,
|
||||
id: u64,
|
||||
text: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError>;
|
||||
/// Closes the stdin of process `id`.
|
||||
fn close_process_stdin(&self, id: u64) -> Result<(), HostError>;
|
||||
/// Ends process `id`; not an error if it already ended.
|
||||
fn kill_process(&self, id: u64) -> Result<(), HostError>;
|
||||
/// Forgets the handle and ends its process if it still runs.
|
||||
fn release_process(&self, id: u64);
|
||||
/// Waits up to `timeout` for process `id` to end (`ZERO` only checks) and
|
||||
/// returns what it printed meanwhile with its status once it ended.
|
||||
fn wait_process(
|
||||
&self,
|
||||
id: u64,
|
||||
max: usize,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<ProcessChunk, HostError>;
|
||||
/// Starts a progress indicator; cancelling it from the UI cancels `cancel`.
|
||||
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64;
|
||||
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>);
|
||||
@@ -453,8 +496,10 @@ pub struct StoreData {
|
||||
pub(crate) granted: Vec<Capability>,
|
||||
pub(crate) host: Arc<dyn HostApi>,
|
||||
pub(crate) invocation: Invocation,
|
||||
/// The running call is a command or an event, so it may ask the user.
|
||||
/// The running call is a command or an event.
|
||||
pub(crate) interactive: bool,
|
||||
/// Editor window for pick, pick-many, input and confirm during action resolution.
|
||||
pub(crate) action_prompt_client: Option<String>,
|
||||
/// Tasks begun by the running call and not ended yet.
|
||||
pub(crate) open_tasks: Vec<u64>,
|
||||
pub(crate) last_report: Option<Instant>,
|
||||
@@ -587,6 +632,7 @@ struct Exports {
|
||||
views: Option<wit_view::Guest>,
|
||||
semantic: Option<wit_sem::Guest>,
|
||||
details: Option<wit_detail::Guest>,
|
||||
diagnostics: Option<crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider::Guest>,
|
||||
hover: Option<wit_hover::Guest>,
|
||||
commit_checks: Option<wit_cc::Guest>,
|
||||
inlay: Option<wit_inlay::Guest>,
|
||||
@@ -643,6 +689,7 @@ impl Runtime {
|
||||
host: setup.host,
|
||||
invocation: Invocation::default(),
|
||||
interactive: false,
|
||||
action_prompt_client: None,
|
||||
open_tasks: Vec::new(),
|
||||
last_report: None,
|
||||
};
|
||||
@@ -673,6 +720,8 @@ impl Runtime {
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
|
||||
let detail_indices = wit_detail::GuestIndices::new(&pre).ok();
|
||||
use crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider as wit_diag;
|
||||
let diagnostics_indices = wit_diag::GuestIndices::new(&pre).ok();
|
||||
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
|
||||
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
|
||||
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
|
||||
@@ -707,6 +756,10 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let diagnostics = match diagnostics_indices {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let hover = match hover_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
@@ -733,6 +786,7 @@ impl Runtime {
|
||||
indexer,
|
||||
semantic,
|
||||
details,
|
||||
diagnostics,
|
||||
hover,
|
||||
commit_checks,
|
||||
inlay,
|
||||
@@ -944,6 +998,7 @@ impl Runtime {
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
action: &CodeAction,
|
||||
invocation: Invocation,
|
||||
timeout: Duration,
|
||||
) -> Result<CodeAction, CallError> {
|
||||
if action.file_action {
|
||||
@@ -953,13 +1008,20 @@ impl Runtime {
|
||||
let d = file_action_doc(doc);
|
||||
let a = file_action_to_wit(action);
|
||||
let resolved = self.call(Invocation::default(), timeout, |e, store| {
|
||||
store.data_mut().action_prompt_client = invocation.client;
|
||||
e.file_actions.as_ref().expect("checked").call_resolve(store, &d, &a)
|
||||
})?;
|
||||
return resolved.map(file_action_from_wit).map_err(|e| CallError::Guest(e.into()));
|
||||
}
|
||||
let d = doc_to_wit(doc);
|
||||
let a = code_action_to_wit(action);
|
||||
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
|
||||
if self.exports.providers.is_none() {
|
||||
return Err(CallError::missing_export("providers"));
|
||||
}
|
||||
let resolved = self.call(Invocation::default(), timeout, |e, store| {
|
||||
store.data_mut().action_prompt_client = invocation.client;
|
||||
e.providers.as_ref().expect("checked").call_resolve_code_action(store, &d, &a)
|
||||
})?;
|
||||
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
|
||||
}
|
||||
|
||||
@@ -986,7 +1048,7 @@ impl Runtime {
|
||||
.take(MAX_PROVIDER_ITEMS)
|
||||
.map(|m| LineMarker {
|
||||
line: m.line,
|
||||
icon: m.icon.map(|i| clip(i, 32)),
|
||||
icon: m.icon.map(|i| clip(i, 256)),
|
||||
tooltip: clip(m.tooltip, 1024),
|
||||
target: m.target.map(location_from_wit),
|
||||
})
|
||||
@@ -1021,6 +1083,27 @@ impl Runtime {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn provide_diagnostics(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<ide_core::Diagnostic>, CallError> {
|
||||
use crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider as w;
|
||||
if self.exports.diagnostics.is_none() {
|
||||
return Err(CallError::missing_export("diagnostics-provider"));
|
||||
}
|
||||
let d = w::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let items = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.diagnostics.as_ref().expect("checked").call_diagnostics(store, &d)
|
||||
})?;
|
||||
Ok(crate::wit_host::diagnostics_from_wit(items, &self.store.data().plugin_id))
|
||||
}
|
||||
|
||||
pub fn provide_hover(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
@@ -1281,6 +1364,7 @@ impl Runtime {
|
||||
let data = self.store.data_mut();
|
||||
data.invocation = Invocation::default();
|
||||
data.interactive = false;
|
||||
data.action_prompt_client = None;
|
||||
// Tasks don't outlive the call that began them.
|
||||
for id in std::mem::take(&mut data.open_tasks) {
|
||||
data.host.task_end(id);
|
||||
|
||||
@@ -352,6 +352,24 @@ pub struct ProcessOutput {
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// What to run: the approved file of `process:<program>`, its arguments and
|
||||
/// the environment to add to the inherited one (WIT `process.command`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProcessCommand {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// What a kept-running program printed since the last read, and its exit
|
||||
/// status once it ended (WIT `process.chunk`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProcessChunk {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub status: Option<i32>,
|
||||
}
|
||||
|
||||
/// Where `net:` requests may go: the URL's host when it is `https`, or
|
||||
/// `http` to the loopback interface.
|
||||
pub fn net_host(url: &str) -> Result<String, String> {
|
||||
@@ -415,6 +433,15 @@ pub struct Location {
|
||||
pub range: ide_core::Range,
|
||||
}
|
||||
|
||||
/// A symbol in the current project index. Ranges use UTF-16 columns.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SymbolInfo {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub container: Option<String>,
|
||||
pub location: Location,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CompletionItem {
|
||||
pub label: String,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
use crate::runtime::{MAX_TITLE, StoreData, clip};
|
||||
use crate::sandbox;
|
||||
use crate::types::{
|
||||
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, Prompt, StatusItem, net_host,
|
||||
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, ProcessChunk, ProcessCommand,
|
||||
Prompt, StatusItem, net_host,
|
||||
};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_extension::Capability;
|
||||
@@ -23,6 +24,7 @@ pub(crate) mod bindings {
|
||||
"openidea:plugin/editor.text-editor": super::EditorRes,
|
||||
"openidea:plugin/invocation.invocation": super::InvocationRes,
|
||||
"openidea:plugin/tasks.task": super::TaskRes,
|
||||
"openidea:plugin/process.child": super::ChildRes,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -124,6 +126,19 @@ pub struct ProjectRes {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
/// A program a plugin started behind a handle (`process.start`); dropping the
|
||||
/// handle ends it if it still runs.
|
||||
pub struct ChildRes {
|
||||
id: u64,
|
||||
host: std::sync::Arc<dyn crate::HostApi>,
|
||||
}
|
||||
|
||||
impl Drop for ChildRes {
|
||||
fn drop(&mut self) {
|
||||
self.host.release_process(self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The invoking window's focused editor.
|
||||
pub struct EditorRes {
|
||||
ctx: EditorContext,
|
||||
@@ -143,6 +158,15 @@ const MAX_FIND: u32 = 10_000;
|
||||
const MAX_STATUS_TEXT: usize = 128;
|
||||
const MAX_PICK_ITEMS: usize = 5000;
|
||||
const MAX_PICK_KEY: usize = 256;
|
||||
const MAX_ARGS: usize = 256;
|
||||
const MAX_ARG_BYTES: usize = 4096;
|
||||
const MAX_ENV: usize = 64;
|
||||
const MAX_ENV_NAME: usize = 256;
|
||||
const MAX_ENV_VALUE: usize = 4096;
|
||||
/// Most output one `poll` / `wait` hands out, per stream.
|
||||
const MAX_CHUNK: usize = 1 << 20;
|
||||
/// Most text one `write` puts into a process's stdin.
|
||||
const MAX_STDIN_BYTES: usize = 1 << 20;
|
||||
|
||||
type Fallible<T> = wasmtime::Result<Result<T, wit::Error>>;
|
||||
|
||||
@@ -246,8 +270,16 @@ impl StoreData {
|
||||
/// Asks the user and waits; the wait doesn't count against the call's
|
||||
/// wall-clock budget.
|
||||
fn ask(&mut self, prompt: Prompt) -> Result<Option<Vec<String>>, wit::Error> {
|
||||
self.require_interactive()?;
|
||||
let client = self.invocation.client.clone();
|
||||
let client = match (&prompt, &self.action_prompt_client) {
|
||||
(
|
||||
Prompt::QuickPick { .. } | Prompt::Input { multiline: false, .. } | Prompt::Confirm { .. },
|
||||
Some(client),
|
||||
) => Some(client.clone()),
|
||||
_ => {
|
||||
self.require_interactive()?;
|
||||
self.invocation.client.clone()
|
||||
}
|
||||
};
|
||||
let started = Instant::now();
|
||||
let answer = self.host.ask(client.as_deref(), prompt, &self.cancel.token());
|
||||
let waited = started.elapsed();
|
||||
@@ -279,7 +311,7 @@ impl StoreData {
|
||||
self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple })
|
||||
}
|
||||
|
||||
/// Dialogs belong to commands and events; rendering and providers have no one to ask.
|
||||
/// APIs reserved for commands and events exclude code-action resolution.
|
||||
fn require_interactive(&self) -> Result<(), wit::Error> {
|
||||
if self.interactive {
|
||||
return Ok(());
|
||||
@@ -479,6 +511,39 @@ impl workspace::Host for StoreData {
|
||||
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
|
||||
}
|
||||
|
||||
fn symbols(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
query: String,
|
||||
limit: u32,
|
||||
) -> Fallible<Vec<workspace::SymbolInfo>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if query.len() > 256 {
|
||||
return Ok(Err(wit::Error::Invalid("symbol query exceeds 256 UTF-8 bytes".into())));
|
||||
}
|
||||
let root = &self.table.get(&project)?.root;
|
||||
Ok(self
|
||||
.host
|
||||
.symbols(root, &query, limit.min(1000) as usize, &self.cancel.token(), self.deadline)
|
||||
.map(|symbols| {
|
||||
symbols
|
||||
.into_iter()
|
||||
.map(|s| workspace::SymbolInfo {
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
container: s.container,
|
||||
location: wit::Location {
|
||||
path: to_ipc(&s.location.path),
|
||||
range: range_to_wit(&s.location.range),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
|
||||
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -857,26 +922,7 @@ impl diagnostics::Host for StoreData {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let source = self.plugin_id.clone();
|
||||
let items = items
|
||||
.into_iter()
|
||||
.take(MAX_DIAGNOSTICS)
|
||||
.map(|d| ide_core::Diagnostic {
|
||||
range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)),
|
||||
severity: match d.severity {
|
||||
diagnostics::Severity::Error => ide_core::Severity::Error,
|
||||
diagnostics::Severity::Warning => ide_core::Severity::Warning,
|
||||
diagnostics::Severity::Info => ide_core::Severity::Info,
|
||||
diagnostics::Severity::Hint => ide_core::Severity::Hint,
|
||||
},
|
||||
message: clip(d.message, 4096),
|
||||
source: Some(d.source.map_or_else(|| source.clone(), |s| clip(s, 128))),
|
||||
code: d.code.map(|c| clip(c, 128)),
|
||||
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
|
||||
deprecated: d.tags.iter().any(|t| t == "deprecated"),
|
||||
server: Some(source.clone()),
|
||||
})
|
||||
.collect();
|
||||
let items = diagnostics_from_wit(items, &self.plugin_id);
|
||||
Ok(self.host.publish_diagnostics(Path::new(&path), items).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
@@ -925,6 +971,45 @@ impl secrets::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreData {
|
||||
/// The `command` of `process.run-command` / `process.start`, checked: the
|
||||
/// program is the one the user approved, the sizes stay in the limits.
|
||||
fn command(&self, cmd: process::Command) -> Result<ProcessCommand, wit::Error> {
|
||||
self.require(Capability::Process(cmd.program.clone()))?;
|
||||
if cmd.args.len() > MAX_ARGS || cmd.args.iter().any(|a| a.len() > MAX_ARG_BYTES) {
|
||||
return Err(wit::Error::Invalid(format!("at most {MAX_ARGS} arguments of at most {MAX_ARG_BYTES} bytes")));
|
||||
}
|
||||
if cmd.env.len() > MAX_ENV {
|
||||
return Err(wit::Error::Invalid(format!("at most {MAX_ENV} environment variables")));
|
||||
}
|
||||
for (name, value) in &cmd.env {
|
||||
if name.is_empty() || name.len() > MAX_ENV_NAME || name.contains(['=', '\0']) {
|
||||
return Err(wit::Error::Invalid(format!("{name:?} is not an environment variable name")));
|
||||
}
|
||||
if value.len() > MAX_ENV_VALUE || value.contains('\0') {
|
||||
return Err(wit::Error::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
|
||||
}
|
||||
}
|
||||
Ok(ProcessCommand { program: cmd.program, args: cmd.args, env: cmd.env })
|
||||
}
|
||||
|
||||
/// The project a call runs in, when it named one.
|
||||
fn cwd_of(&self, cwd: Option<Resource<ProjectRes>>) -> wasmtime::Result<Option<PathBuf>> {
|
||||
Ok(match &cwd {
|
||||
Some(p) => Some(self.table.get(p)?.root.clone()),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn to_chunk(chunk: ProcessChunk) -> process::Chunk {
|
||||
process::Chunk { stdout: chunk.stdout, stderr: chunk.stderr, status: chunk.status }
|
||||
}
|
||||
|
||||
fn to_output(output: crate::types::ProcessOutput) -> process::Output {
|
||||
process::Output { status: output.status, stdout: output.stdout, stderr: output.stderr }
|
||||
}
|
||||
|
||||
impl process::Host for StoreData {
|
||||
fn run(
|
||||
&mut self,
|
||||
@@ -933,20 +1018,81 @@ impl process::Host for StoreData {
|
||||
cwd: Option<Resource<ProjectRes>>,
|
||||
stdin: Option<String>,
|
||||
) -> Fallible<process::Output> {
|
||||
if let Err(e) = self.require(Capability::Process(program.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let cwd = match &cwd {
|
||||
Some(p) => Some(self.table.get(p)?.root.clone()),
|
||||
None => None,
|
||||
let cmd = match self.command(process::Command { program, args, env: Vec::new() }) {
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let cwd = self.cwd_of(cwd)?;
|
||||
let token = self.cancel.token();
|
||||
let timeout = self.remaining();
|
||||
Ok(self
|
||||
.host
|
||||
.run_process(&program, args, cwd.as_deref(), stdin, &token, timeout)
|
||||
.map(|o| process::Output { status: o.status, stdout: o.stdout, stderr: o.stderr })
|
||||
.map_err(to_wit))
|
||||
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn run_command(
|
||||
&mut self,
|
||||
cmd: process::Command,
|
||||
cwd: Option<Resource<ProjectRes>>,
|
||||
stdin: Option<String>,
|
||||
) -> Fallible<process::Output> {
|
||||
let cmd = match self.command(cmd) {
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let cwd = self.cwd_of(cwd)?;
|
||||
let token = self.cancel.token();
|
||||
let timeout = self.remaining();
|
||||
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn start(&mut self, cmd: process::Command, cwd: Option<Resource<ProjectRes>>) -> Fallible<Resource<ChildRes>> {
|
||||
let cmd = match self.command(cmd) {
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let cwd = self.cwd_of(cwd)?;
|
||||
let id = match self.host.start_process(cmd, cwd.as_deref(), self.invocation.client.as_deref()) {
|
||||
Ok(id) => id,
|
||||
Err(e) => return Ok(Err(to_wit(e))),
|
||||
};
|
||||
self.push(ChildRes { id, host: self.host.clone() }).map(Ok)
|
||||
}
|
||||
}
|
||||
|
||||
impl process::HostChild for StoreData {
|
||||
fn poll(&mut self, this: Resource<ChildRes>, max_bytes: u32) -> Fallible<process::Chunk> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
let max = (max_bytes as usize).min(MAX_CHUNK);
|
||||
Ok(self.host.poll_process(id, max).map(to_chunk).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn wait(&mut self, this: Resource<ChildRes>, timeout_ms: u32) -> Fallible<process::Chunk> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
let timeout = Duration::from_millis(u64::from(timeout_ms)).min(self.remaining());
|
||||
let token = self.cancel.token();
|
||||
Ok(self.host.wait_process(id, MAX_CHUNK, timeout, &token).map(to_chunk).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn write(&mut self, this: Resource<ChildRes>, text: String) -> Fallible<()> {
|
||||
if text.len() > MAX_STDIN_BYTES {
|
||||
return Ok(Err(wit::Error::Invalid(format!("at most {MAX_STDIN_BYTES} bytes at a time"))));
|
||||
}
|
||||
let id = self.table.get(&this)?.id;
|
||||
Ok(self.host.write_process(id, &text, &self.cancel.token(), self.remaining()).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn close_stdin(&mut self, this: Resource<ChildRes>) -> Fallible<()> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
Ok(self.host.close_process_stdin(id).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn kill(&mut self, this: Resource<ChildRes>) -> Fallible<()> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
Ok(self.host.kill_process(id).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<ChildRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(rep)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,7 +1273,7 @@ fn too_much_to_write(path: &str) -> wit::Error {
|
||||
}
|
||||
|
||||
/// Largest message between a plugin and its view's frame.
|
||||
const MAX_VIEW_MESSAGE: usize = 1 << 20;
|
||||
const MAX_VIEW_MESSAGE: usize = 8 << 20;
|
||||
|
||||
pub(crate) mod indexing_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
@@ -1232,3 +1378,35 @@ mod project_ui_tests {
|
||||
assert!(validate_ui_path(&format!("/{}", "x".repeat(8192))).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod diagnostics_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "editor-diagnostics", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn diagnostics_from_wit(items: Vec<diagnostics::Diagnostic>, source: &str) -> Vec<ide_core::Diagnostic> {
|
||||
items
|
||||
.into_iter()
|
||||
.take(MAX_DIAGNOSTICS)
|
||||
.map(|d| ide_core::Diagnostic {
|
||||
range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)),
|
||||
severity: match d.severity {
|
||||
diagnostics::Severity::Error => ide_core::Severity::Error,
|
||||
diagnostics::Severity::Warning => ide_core::Severity::Warning,
|
||||
diagnostics::Severity::Info => ide_core::Severity::Info,
|
||||
diagnostics::Severity::Hint => ide_core::Severity::Hint,
|
||||
},
|
||||
message: clip(d.message, 4096),
|
||||
source: Some(d.source.map_or_else(|| source.to_owned(), |s| clip(s, 128))),
|
||||
code: d.code.map(|c| clip(c, 128)),
|
||||
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
|
||||
deprecated: d.tags.iter().any(|t| t == "deprecated"),
|
||||
server: Some(source.to_owned()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -30,7 +30,9 @@ fn components() -> Vec<PathBuf> {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"),
|
||||
];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
95de177eb6e9db39e0a9d5acb86ac1b0f19d91c954590135748774ba83638b53
|
||||
24726dac5ce264f5d451956def099e0002ec721e4828bc7852ad1dcabcb4bcba
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-detail-views"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "openidea-plugin-detail-views"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# Standalone: not a member of the IDE workspace (different target).
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
833c378fabd0c3ec71e5c372e51049cb4fcd8d58e4992199eaf6090b3c1235d8
|
||||
@@ -0,0 +1,24 @@
|
||||
id = "test.detail-views"
|
||||
name = "Detail views"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-view:tree", "on-view:list", "on-view:services"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "tree"
|
||||
title = "Detail Tree"
|
||||
location = "left"
|
||||
detail = true
|
||||
|
||||
[[contributes.views]]
|
||||
id = "list"
|
||||
title = "Detail List"
|
||||
location = "bottom"
|
||||
detail = true
|
||||
|
||||
[[contributes.views]]
|
||||
id = "services"
|
||||
title = "Detail Services"
|
||||
location = "services"
|
||||
detail = true
|
||||
@@ -0,0 +1,64 @@
|
||||
//! A tree/list detail-provider fixture for host and native UI verification.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use openidea_plugin::details::{Details, Pane, Tab};
|
||||
use openidea_plugin::views::{Body, Button, ListItem, TreeItem, View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct DetailViews {
|
||||
selected: BTreeMap<String, String>,
|
||||
removed: bool,
|
||||
revision: u32,
|
||||
}
|
||||
|
||||
impl Plugin for DetailViews {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Views for DetailViews {
|
||||
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
|
||||
let keys: &[&str] = if self.removed { &["beta"] } else { &["alpha", "beta"] };
|
||||
let body = if view == "list" {
|
||||
Body::List(keys.iter().map(|key| ListItem::new(key, key)).collect())
|
||||
} else {
|
||||
Body::Tree(keys.iter().map(|key| TreeItem::new(key, key)).collect())
|
||||
};
|
||||
Ok(View::new(body)
|
||||
.button(Button::new("refresh", "Refresh details"))
|
||||
.button(Button::new("remove", "Remove alpha")))
|
||||
}
|
||||
|
||||
fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
|
||||
match event {
|
||||
ViewEvent::Select(key) => {
|
||||
self.selected.insert(view.to_owned(), key);
|
||||
}
|
||||
ViewEvent::Button(id) => {
|
||||
if id == "remove" {
|
||||
self.removed = true;
|
||||
}
|
||||
self.revision += 1;
|
||||
host::refresh_view(view);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Details for DetailViews {
|
||||
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
|
||||
if self.selected.get(view).map(String::as_str) != Some(key) {
|
||||
return Err(Error::Failed("select must finish before detail".into()));
|
||||
}
|
||||
Ok(vec![
|
||||
Tab::new("info", "Info", Pane::Text(format!("{view}: {key}\nRevision {}", self.revision))),
|
||||
Tab::new("message", "Summary", Pane::Message(format!("Selected {key}"))),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(DetailViews, views, details);
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-diagnostics"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "openidea-test-diagnostics"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
diagnostics-first = []
|
||||
diagnostics-last = []
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,6 @@
|
||||
# Diagnostics provider fixture
|
||||
|
||||
Build with `plugins/build.sh crates/ide-plugin/tests/fixtures/diagnostics`.
|
||||
Used by `cargo test -p ide-host --test plugin_diagnostics` for real Wasm dispatch,
|
||||
UTF-16 ranges, debouncing, cancellation, window/project lifecycle and coexistence
|
||||
with manually published diagnostics. Not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
f757407f530839b8c43697e310926c5b141231bd8f2781ac92eee3a9de3f0f30
|
||||
@@ -0,0 +1,23 @@
|
||||
id = "test.diagnostics"
|
||||
name = "Diagnostics fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
[capabilities]
|
||||
required = ["project.read"]
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["diagnostics"]
|
||||
languages = ["text", "plaintext"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "publish"
|
||||
title = "Publish fixture diagnostic"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "calls"
|
||||
title = "Diagnostics fixture call count"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "wait"
|
||||
title = "Wait for diagnostics fixture"
|
||||
@@ -0,0 +1,169 @@
|
||||
use openidea_plugin::diagnostics::{DiagnosticsProvider, Document};
|
||||
use openidea_plugin::{Diagnostic, Error, Invocation, Plugin, Range, Severity, host, register_plugin, text};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture {
|
||||
calls: u64,
|
||||
}
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"wait" => {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
Ok(Some("finished".into()))
|
||||
}
|
||||
"calls" => Ok(Some(self.calls.to_string())),
|
||||
"publish" => host::publish_diagnostics(
|
||||
arg.unwrap_or_default(),
|
||||
&[Diagnostic::new(Range::default(), Severity::Info, "Manual problem")],
|
||||
)
|
||||
.map(|()| None),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagnosticsProvider for Fixture {
|
||||
fn diagnostics(&mut self, doc: &Document) -> Vec<Diagnostic> {
|
||||
self.calls += 1;
|
||||
if doc.text == "limits" {
|
||||
let mut diagnostic = Diagnostic::new(Range::default(), Severity::Hint, "m".repeat(5000));
|
||||
diagnostic.source = Some("s".repeat(200));
|
||||
diagnostic.code = Some("c".repeat(200));
|
||||
diagnostic.unnecessary = true;
|
||||
diagnostic.deprecated = true;
|
||||
return vec![diagnostic; 5001];
|
||||
}
|
||||
if doc.text.starts_with("slow") {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
if doc.text.starts_with("spin") {
|
||||
loop {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
if doc.text.starts_with("panic") {
|
||||
panic!("diagnostics fixture failed");
|
||||
}
|
||||
doc.text
|
||||
.match_indices("BAD")
|
||||
.map(|(offset, _)| {
|
||||
Diagnostic::new(
|
||||
Range { start: text::position(&doc.text, offset), end: text::position(&doc.text, offset + 3) },
|
||||
Severity::Warning,
|
||||
format!("BAD in {} at version {}", doc.language, doc.version),
|
||||
)
|
||||
.code("bad-word")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::hover::HoverProvider for Fixture {
|
||||
fn hover(&mut self, _: &Document, _: openidea_plugin::Position) -> Option<openidea_plugin::hover::Hover> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::inlay_hints::InlayHints for Fixture {
|
||||
fn inlay_hints(&mut self, _: &Document, _: Range) -> Vec<openidea_plugin::inlay_hints::InlayHint> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::providers::Providers for Fixture {}
|
||||
|
||||
impl openidea_plugin::endpoints::EndpointsProvider for Fixture {
|
||||
fn endpoints(&mut self, _: &Invocation) -> Result<Vec<openidea_plugin::endpoints::Endpoint>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::views::Views for Fixture {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &str,
|
||||
_: Option<openidea_plugin::views::Viewport>,
|
||||
_: &Invocation,
|
||||
) -> Result<openidea_plugin::views::View, Error> {
|
||||
Ok(openidea_plugin::views::View::message(""))
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::indexing::DocumentIndexer for Fixture {
|
||||
fn index(&mut self, _: &openidea_plugin::indexing::Document) -> openidea_plugin::indexing::Edges {
|
||||
openidea_plugin::indexing::Edges::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::syntax::SyntaxProvider for Fixture {
|
||||
fn highlights(&mut self, _: &str, _: &str) -> Vec<openidea_plugin::syntax::Token> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::semantic_tokens::SemanticTokens for Fixture {
|
||||
fn semantic_tokens(&mut self, _: &Document) -> Vec<openidea_plugin::semantic_tokens::SemanticToken> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::details::Details for Fixture {
|
||||
fn detail(&mut self, _: &str, _: &str, _: &Invocation) -> Result<Vec<openidea_plugin::details::Tab>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::commit::CommitChecks for Fixture {
|
||||
fn check(
|
||||
&mut self,
|
||||
_: &str,
|
||||
_: &openidea_plugin::CommitDraft,
|
||||
_: &Invocation,
|
||||
) -> Result<Vec<openidea_plugin::commit::CommitProblem>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::file_actions::FileActions for Fixture {
|
||||
fn file_actions(&mut self, _: &Document, _: Range) -> Vec<openidea_plugin::file_actions::FileAction> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostics-first")]
|
||||
register_plugin!(
|
||||
Fixture,
|
||||
diagnostics,
|
||||
endpoints,
|
||||
views,
|
||||
indexing,
|
||||
syntax,
|
||||
semantic_tokens,
|
||||
hover,
|
||||
providers,
|
||||
inlay_hints,
|
||||
details,
|
||||
commit_checks,
|
||||
file_actions
|
||||
);
|
||||
#[cfg(all(feature = "diagnostics-last", not(feature = "diagnostics-first")))]
|
||||
register_plugin!(
|
||||
Fixture,
|
||||
file_actions,
|
||||
commit_checks,
|
||||
details,
|
||||
inlay_hints,
|
||||
providers,
|
||||
hover,
|
||||
semantic_tokens,
|
||||
syntax,
|
||||
indexing,
|
||||
views,
|
||||
endpoints,
|
||||
diagnostics
|
||||
);
|
||||
#[cfg(not(any(feature = "diagnostics-first", feature = "diagnostics-last")))]
|
||||
register_plugin!(Fixture, diagnostics);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0f9992726659db0400c43192a623ecf97977766e0d7282f99e5ba800fdf247c1
|
||||
99cba883e8030d7679db366a4b18b8c456afccbcbc949e0d7411adc6411f2e00
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-file-templates"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-file-templates"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
afcf583238dddd20d0d709ce7619fada0e60916f9e3c039569d37dec75354471
|
||||
@@ -0,0 +1,38 @@
|
||||
id = "file-templates"
|
||||
name = "File Templates"
|
||||
version = "0.1.0"
|
||||
description = "API 1.3 fixture: exact filenames, variables and inferred packages."
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:ping"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "ping"
|
||||
title = "File Templates: Ping"
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "dockerignore"
|
||||
name = "Docker ignore"
|
||||
extension = "dockerignore"
|
||||
fileName = ".dockerignore"
|
||||
path = "templates/dockerignore.txt"
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "attributes"
|
||||
name = "Git attributes"
|
||||
fileName = ".gitattributes"
|
||||
path = "templates/attributes.txt"
|
||||
variables = [{ name = "PATTERN", prompt = "File pattern" }, { name = "FILTER", prompt = "Attribute filter" }]
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "repository"
|
||||
name = "Java repository"
|
||||
extension = "java"
|
||||
path = "templates/repository.txt"
|
||||
variables = [{ name = "ENTITY", prompt = "Entity class" }, { name = "TABLE", prompt = "Table name" }]
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "kotlin"
|
||||
name = "Kotlin entity"
|
||||
extension = "kt"
|
||||
path = "templates/kotlin.txt"
|
||||
@@ -0,0 +1,15 @@
|
||||
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FileTemplates;
|
||||
|
||||
impl Plugin for FileTemplates {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"ping" => Ok(Some("File templates ready".into())),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(FileTemplates);
|
||||
@@ -0,0 +1 @@
|
||||
$PATTERN$ filter=$FILTER$
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,5 @@
|
||||
package $PACKAGE$
|
||||
|
||||
class $NAME$ {
|
||||
$END$
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package $PACKAGE$;
|
||||
|
||||
// Table: $TABLE$
|
||||
public interface $NAME$ {
|
||||
$ENTITY$ find();
|
||||
$END$
|
||||
}
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-grouped-views"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-grouped-views"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
a1ce41144630be398bb63cffca26005955de0511a17c71dc286e7762b19e9cab
|
||||
@@ -0,0 +1,37 @@
|
||||
id = "grouped-views"
|
||||
name = "Grouped Views"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*", "on-view:*"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "show"
|
||||
title = "Grouped Views: Show"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "hide"
|
||||
title = "Grouped Views: Hide"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "reveal"
|
||||
title = "Grouped Views: Reveal"
|
||||
|
||||
[[contributes.views]]
|
||||
id = "overview"
|
||||
title = "Grouped Views"
|
||||
location = "right"
|
||||
group = "demo"
|
||||
when = "plugin.grouped-views.ready"
|
||||
|
||||
[[contributes.views]]
|
||||
id = "files"
|
||||
title = "Project Files"
|
||||
location = "right"
|
||||
group = "demo"
|
||||
when = "workspaceContains:grouped-views.txt"
|
||||
|
||||
[[contributes.views]]
|
||||
id = "legacy"
|
||||
title = "Ungrouped View"
|
||||
location = "left"
|
||||
@@ -0,0 +1,34 @@
|
||||
use openidea_plugin::views::{Body, View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct GroupedViews;
|
||||
|
||||
impl Plugin for GroupedViews {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"show" => host::set_context("ready", true)?,
|
||||
"hide" => host::set_context("ready", false)?,
|
||||
"reveal" => host::reveal_view(arg.unwrap_or("overview"))?,
|
||||
_ => return Err(Error::NotFound(id.into())),
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Views for GroupedViews {
|
||||
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
|
||||
match view {
|
||||
"overview" => Ok(View::new(Body::Message("Context condition is true".into()))),
|
||||
"files" => Ok(View::new(Body::Message("Project contains grouped-views.txt".into()))),
|
||||
"legacy" => Ok(View::new(Body::Message("Ungrouped view keeps its original window id".into()))),
|
||||
_ => Err(Error::NotFound(view.into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, _view: &str, _event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(GroupedViews, views);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
7a77d431efd04f0fd5b864cd30216e655b67f126d8a4dd4506dcf805a3454076
|
||||
bbfc134f2121f301bbbcb0f0d596d7b3cc059455bba5ca78992e8bfa6630ce42
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
5cf52999f65c757d0eb793934c28ae6d9f453053514c2b4c8058d6e70384eb9a
|
||||
b7f45420859ce9540e0aae1fd803eee13529ef63d0a2f0b6ba9e43a5958eb457
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
07839dc01f3d528a81969703271d6c7ad1397b142bdc3aff68d7384249a15818
|
||||
041aa03873ff1d8133f181e86aab2ad3fda41e46503886754114f3abef3643da
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
7ef04eaa3f6d506291c89877de26a11b6a27df900c223ce53bf1749523839ff6
|
||||
edcad61ab8e71cff2297edd9b902359d76ef04446bdfc42aae34420ced4d7f26
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
bd79d7641d84e919a83ea082b00eca78d4d1427e1e3345fba7d289b06862edde
|
||||
aad3a25c3522fbff6f48c0c1e9de23b6d0d5361ba6658b744ad1590e9fdbe255
|
||||
@@ -141,6 +141,7 @@ impl Views for Jobs {
|
||||
ViewEvent::Button(b) if b == "follow" => return self.follow(),
|
||||
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
|
||||
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
|
||||
ViewEvent::Message(msg) => return host::post_view_message(VIEW, &msg),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
host::refresh_view(VIEW);
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
95835b883dec1cf9b053e455533ed0329b6fb1b9270832865b7d8fd05fbaff5a
|
||||
cfb432ea8943bf9b7e2f3ad3708df35260eebd04b3ac059c7643b378a35cd036
|
||||
@@ -8,6 +8,7 @@ activation = ["on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["project.read"]
|
||||
optional = ["process:git"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "echo"
|
||||
@@ -64,3 +65,39 @@ title = "subscribe"
|
||||
[[contributes.commands]]
|
||||
id = "diagnose"
|
||||
title = "diagnose"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "child"
|
||||
title = "child"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "envrun"
|
||||
title = "envrun"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "envstart"
|
||||
title = "envstart"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "envdeny-run"
|
||||
title = "envdeny-run"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "envdeny-start"
|
||||
title = "envdeny-start"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "childkeep"
|
||||
title = "childkeep"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "childpoll"
|
||||
title = "childpoll"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "childdrop"
|
||||
title = "childdrop"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "childtrap"
|
||||
title = "childtrap"
|
||||
+186
-4
@@ -20,10 +20,14 @@ use exports::openidea::plugin::providers::{
|
||||
};
|
||||
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
|
||||
use exports::openidea::plugin::{commands, event_handler, lifecycle};
|
||||
use openidea::plugin::{invocation::Invocation, types};
|
||||
use openidea::plugin::{invocation::Invocation, process, types};
|
||||
|
||||
struct Misbehave;
|
||||
|
||||
thread_local! {
|
||||
static CHILDREN: std::cell::RefCell<Vec<process::Child>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn failed(message: &str) -> types::Error {
|
||||
types::Error::Failed(message.into())
|
||||
}
|
||||
@@ -92,6 +96,73 @@ impl commands::Guest for Misbehave {
|
||||
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
|
||||
project.write_file(&arg.unwrap_or_default(), "written inside the project\n").map(|()| None)
|
||||
}
|
||||
|
||||
// API 1.3 `process.start`: a program kept running behind a handle.
|
||||
"child" => {
|
||||
let cmd = process::Command {
|
||||
program: "git".into(),
|
||||
args: vec!["hash-object".into(), "--stdin".into()],
|
||||
env: Vec::new(),
|
||||
};
|
||||
let child = process::start(&cmd, None)?;
|
||||
child.write("hello\n")?;
|
||||
child.close_stdin()?;
|
||||
let chunk = child.wait(30_000)?;
|
||||
child.kill()?;
|
||||
Ok(Some(format!("{} {:?}", chunk.stdout.trim(), chunk.status)))
|
||||
}
|
||||
"childkeep" | "childtrap" => {
|
||||
let cmd = process::Command {
|
||||
program: "git".into(),
|
||||
args: vec![
|
||||
"hash-object".into(),
|
||||
"--stdin".into(),
|
||||
format!("--path={}", arg.unwrap_or_else(|| "openidea-process-2027".into())),
|
||||
],
|
||||
env: Vec::new(),
|
||||
};
|
||||
let child = process::start(&cmd, None)?;
|
||||
CHILDREN.with(|children| children.borrow_mut().push(child));
|
||||
if id == "childtrap" {
|
||||
panic!("trap with a live child");
|
||||
}
|
||||
Ok(Some("running".into()))
|
||||
}
|
||||
"childpoll" => CHILDREN.with(|children| {
|
||||
let children = children.borrow();
|
||||
let child = children.last().ok_or_else(|| failed("no child"))?;
|
||||
Ok(Some(format!("{:?}", child.poll(100)?.status)))
|
||||
}),
|
||||
"childdrop" => {
|
||||
CHILDREN.with(|children| children.borrow_mut().clear());
|
||||
Ok(None)
|
||||
}
|
||||
"envrun" | "envstart" | "envdeny-run" | "envdeny-start" => {
|
||||
if arg.as_deref() == Some("legacy") {
|
||||
return Ok(Some(process::run("git", &["--version".into()], None, None)?.stdout));
|
||||
}
|
||||
let env = if id.starts_with("envdeny") {
|
||||
vec![(arg.unwrap_or_else(|| "PATH".into()), "replacement".into())]
|
||||
} else {
|
||||
vec![("OPENIDEA_PROCESS_TEST_VALUE".into(), "added by the plugin".into())]
|
||||
};
|
||||
let cmd = process::Command {
|
||||
program: "git".into(),
|
||||
args: vec![
|
||||
"--config-env=openidea.test=OPENIDEA_PROCESS_TEST_VALUE".into(),
|
||||
"config".into(),
|
||||
"--get".into(),
|
||||
"openidea.test".into(),
|
||||
],
|
||||
env,
|
||||
};
|
||||
if id.ends_with("start") {
|
||||
let child = process::start(&cmd, None)?;
|
||||
Ok(Some(child.wait(30_000)?.stdout))
|
||||
} else {
|
||||
Ok(Some(process::run_command(&cmd, None, None)?.stdout))
|
||||
}
|
||||
}
|
||||
// `editor.write` is not granted: an error, not a trap.
|
||||
"insert" => {
|
||||
let editor = ctx.editor().ok_or_else(|| failed("no editor"))?;
|
||||
@@ -158,9 +229,46 @@ impl syntax_provider::Guest for Misbehave {
|
||||
}
|
||||
}
|
||||
|
||||
fn action_answer(kind: &str) -> Result<String, types::Error> {
|
||||
use openidea::plugin::{ui, views};
|
||||
let items = vec![
|
||||
ui::PickItem { key: "a".into(), label: "Alpha".into(), description: None },
|
||||
ui::PickItem { key: "b".into(), label: "Beta".into(), description: None },
|
||||
];
|
||||
let answer = match kind {
|
||||
"pick" => ui::pick("Choose translation file", &items)?,
|
||||
"pick-many" => ui::pick_many("Choose services", &items)?.map(|keys| keys.join(",")),
|
||||
"input" => ui::input("Service name", Some("Service"))?,
|
||||
"confirm" => ui::confirm("Rewrite", "Apply this rewrite?")?.then(|| "confirmed".into()),
|
||||
"input-multiline" => ui::input_multiline("Not allowed", None)?,
|
||||
"reveal" => {
|
||||
views::reveal("missing")?;
|
||||
None
|
||||
}
|
||||
"open-url" => {
|
||||
ui::open_url("https://example.com")?;
|
||||
None
|
||||
}
|
||||
"clipboard" => {
|
||||
ui::copy_to_clipboard("not allowed")?;
|
||||
None
|
||||
}
|
||||
"pick-directory" => ui::pick_directory("Not allowed", None)?,
|
||||
"open-project" => {
|
||||
openidea::plugin::workspace::open_project("/not-allowed", false)?;
|
||||
None
|
||||
}
|
||||
_ => return Err(types::Error::Invalid("unknown prompt".into())),
|
||||
};
|
||||
answer.ok_or(types::Error::Cancelled)
|
||||
}
|
||||
|
||||
/// Completion: one item, labelled with the document's path, after [`behave`].
|
||||
impl providers::Guest for Misbehave {
|
||||
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
|
||||
if doc.text == "prompt-provider" {
|
||||
assert!(matches!(action_answer("input"), Err(types::Error::Invalid(_))));
|
||||
}
|
||||
behave(&doc.text);
|
||||
// `rich`: a snippet that also adds an import.
|
||||
let rich = doc.text == "rich";
|
||||
@@ -189,6 +297,29 @@ impl providers::Guest for Misbehave {
|
||||
|
||||
/// `lazy`: one action whose edits come from `resolve-code-action`.
|
||||
fn code_actions(doc: Document, _selection: types::Range) -> Vec<CodeAction> {
|
||||
if doc.text == "prompts" {
|
||||
return [
|
||||
"pick",
|
||||
"pick-many",
|
||||
"input",
|
||||
"confirm",
|
||||
"input-multiline",
|
||||
"reveal",
|
||||
"open-url",
|
||||
"clipboard",
|
||||
"pick-directory",
|
||||
"open-project",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|kind| CodeAction {
|
||||
title: format!("Ask {kind}"),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
edits: Vec::new(),
|
||||
data: Some(kind.into()),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if doc.text != "lazy" {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -202,6 +333,19 @@ impl providers::Guest for Misbehave {
|
||||
}
|
||||
|
||||
fn resolve_code_action(doc: Document, action: CodeAction) -> Result<CodeAction, types::Error> {
|
||||
if doc.text == "prompts" {
|
||||
let kind = action.data.as_deref().unwrap_or_default();
|
||||
let answer = action_answer(kind)?;
|
||||
// Longer than the 50 ms provider budget, within the command budget.
|
||||
behave("sleep:80");
|
||||
let start = types::Position { line: 0, character: 0 };
|
||||
let edit = types::TextEdit { range: types::Range { start, end: start }, text: answer };
|
||||
return Ok(CodeAction {
|
||||
edits: vec![FileEdit { path: doc.path, edits: vec![edit] }],
|
||||
data: None,
|
||||
..action
|
||||
});
|
||||
}
|
||||
if action.data.as_deref() != Some("fix") {
|
||||
return Err(types::Error::Invalid("not one of mine".into()));
|
||||
}
|
||||
@@ -214,8 +358,20 @@ impl providers::Guest for Misbehave {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn line_markers(_doc: Document) -> Vec<LineMarker> {
|
||||
Vec::new()
|
||||
fn line_markers(doc: Document) -> Vec<LineMarker> {
|
||||
if !doc.text.starts_with("marker-icons") {
|
||||
return Vec::new();
|
||||
}
|
||||
["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(line, icon)| LineMarker {
|
||||
line: line as u32,
|
||||
icon: Some(icon.into()),
|
||||
tooltip: format!("Marker: {icon}"),
|
||||
target: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +383,17 @@ impl exports::openidea::plugin::file_actions::Guest for Misbehave {
|
||||
_selection: types::Range,
|
||||
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
|
||||
if doc.text == "prompt-files" {
|
||||
return vec![FileAction {
|
||||
title: "Create a service".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: Vec::new(),
|
||||
edits: Vec::new(),
|
||||
open: None,
|
||||
data: Some("input".into()),
|
||||
}];
|
||||
}
|
||||
if doc.text != "files" {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -265,10 +432,25 @@ impl exports::openidea::plugin::file_actions::Guest for Misbehave {
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
_doc: exports::openidea::plugin::file_actions::Document,
|
||||
doc: exports::openidea::plugin::file_actions::Document,
|
||||
action: exports::openidea::plugin::file_actions::FileAction,
|
||||
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
|
||||
if doc.text == "prompt-files" {
|
||||
let name = action_answer("input")?;
|
||||
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir);
|
||||
let path = format!("{dir}/{name}.txt");
|
||||
return Ok(exports::openidea::plugin::file_actions::FileAction {
|
||||
operations: vec![FileOperation::Create(CreateFile {
|
||||
path: path.clone(),
|
||||
text: name,
|
||||
overwrite: false,
|
||||
})],
|
||||
open: Some(path),
|
||||
data: None,
|
||||
..action
|
||||
});
|
||||
}
|
||||
let dir = action.data.clone().ok_or_else(|| types::Error::Invalid("no data".into()))?;
|
||||
let operations = vec![FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/later.txt"),
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
b6c96915f0b0668134bc1b197ad20545f85bd2cb0941a0e83a9e06b688c96434
|
||||
82f53a3f36f965c76575a08609439e0bc9cea37f1aef7cc1671e9ec72a17f120
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
9c0017584418a02395ddf532454ee7440fb044c4b4a77cdf0b554dbd0d84b52c
|
||||
a8d40f2224246e81e015d576a5fa70e83779af9fade614d61212b33fbb0961e9
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
b90537a7745d8e3f8bb9fa0b8d5aa5e06465cf951ab25f5c4881efbeec46dd44
|
||||
e60bcf1e179f6485e14de3755907c93344ef3cdf8158f01bea84343858ba7434
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-symbols"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-symbols"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
5cadc40061284bd5573f8323b02fb7ea7be18eb4323978b3f0b39337a9896774
|
||||
@@ -0,0 +1,23 @@
|
||||
id = "test.symbols"
|
||||
name = "Indexed symbols fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
[capabilities]
|
||||
required = ["project.read"]
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["definition"]
|
||||
languages = ["php", "java"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "symbols"
|
||||
title = "Query indexed symbols"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "remember"
|
||||
title = "Remember project"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "remembered"
|
||||
title = "Query remembered project"
|
||||
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user