Merge remote-tracking branch 'origin/main' into feat/remote-development

# Conflicts:
#	src-tauri/src/state.rs
Цей коміт міститься в:
l0ng-ai committed 2026-10-27 21:13:02 +08:00
коміт f0261719b8
62 files changed
+4656 -143

No files matched your search

Створено
+1
Переглянути файл
@@ -4198,6 +4198,7 @@ dependencies = [
name = "ide-jsonrpc"
version = "0.1.17"
dependencies = [
"encoding_rs",
"futures",
"ide-core",
"parking_lot",
+56 -23
Переглянути файл
@@ -10,6 +10,7 @@
mod config;
mod model;
mod provider;
mod skills;
mod timeline;
mod worktree;
@@ -53,10 +54,12 @@ pub struct AgentPaths {
pub store_dir: PathBuf,
/// Parent of the tasks' worktrees.
pub worktrees_dir: PathBuf,
/// Settings and commands each agent offered last time (shared by all projects).
/// Settings each agent offered last time (shared by all projects), and its commands per project.
pub models_file: PathBuf,
/// User-defined agents, one `<id>.toml` each (`name`, `command`, `args`, `env`).
pub custom_agents_dir: Option<PathBuf>,
/// The user's home: its `.claude` holds their own skills and commands.
pub home_dir: Option<PathBuf>,
}
/// Starts the ACP server of a provider in a directory, speaking to `client`.
@@ -80,8 +83,9 @@ struct Shared {
struct Known {
#[serde(default)]
config: Vec<AgentConfigOption>,
#[serde(default)]
commands: Vec<AgentCommand>,
/// By project root: skills and custom commands differ per project.
#[serde(default, rename = "projectCommands")]
commands: HashMap<String, Vec<AgentCommand>>,
}
impl Shared {
@@ -96,8 +100,18 @@ impl Shared {
}
}
fn known(&self, provider: &str) -> Known {
self.known.lock().get(provider).cloned().unwrap_or_default()
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default()
}
/// The commands the agent offered last time in this project.
fn known_commands(&self, provider: &str) -> Vec<AgentCommand> {
let cache = self.known.lock();
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) {
@@ -109,8 +123,11 @@ impl Shared {
known.config = c.to_vec();
changed = true;
}
if let Some(c) = commands.filter(|c| !c.is_empty() && known.commands != *c) {
known.commands = c.to_vec();
let root = self.root_key();
if let Some(c) =
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
{
known.commands.insert(root, c.to_vec());
changed = true;
}
if !changed {
@@ -238,25 +255,42 @@ impl AgentManager {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let list = specs
.iter()
.map(|p| {
let known = self.shared.known(&p.id);
AgentProvider {
id: p.id.clone(),
name: p.name.clone(),
command: std::iter::once(p.command.as_str())
.chain(p.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" "),
installed: provider::installed(&p.command),
config: known.config,
commands: known.commands,
}
.map(|p| AgentProvider {
id: p.id.clone(),
name: p.name.clone(),
command: std::iter::once(p.command.as_str())
.chain(p.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" "),
installed: provider::installed(&p.command),
config: self.shared.known_config(&p.id),
commands: self.shared.known_commands(&p.id),
})
.collect();
*self.shared.providers.write() = specs;
list
}
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
/// in the project: what the agent offered last time there, plus, for Claude Code, the
/// skills and custom commands on disk it has not listed.
pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult<Vec<AgentCommand>> {
let (provider, offered, cwd) = match task {
Some(id) => {
let task = self.task(id)?;
let s = task.state.lock();
(s.task.provider.clone(), s.task.commands.clone(), s.task.cwd.0.clone())
}
None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()),
};
if provider != "claude" {
return Ok(offered);
}
let home = self.shared.paths.home_dir.clone();
let found = blocking(move || Ok(skills::discover(&cwd, home.as_deref()))).await?;
Ok(skills::merge(offered, found))
}
fn task(&self, id: &str) -> IdeResult<Arc<Task>> {
self.tasks.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no agent task {id}")))
}
@@ -289,8 +323,7 @@ impl AgentManager {
}
};
// Until the session reports its own, show what the agent offered last time with the choices applied.
let known = self.shared.known(&spec.id);
let mut config = known.config;
let mut config = self.shared.known_config(&spec.id);
for choice in &req.config {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
@@ -312,7 +345,7 @@ impl AgentManager {
created_ms: now,
updated_ms: now,
config,
commands: known.commands,
commands: self.shared.known_commands(&spec.id),
usage: None,
queue: Vec::new(),
};
+281
Переглянути файл
@@ -0,0 +1,281 @@
//! Claude Code's slash commands on disk, for completion before (or without) a session:
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
//! of the working directory and every directory above it, then the user's `~/.claude`.
use crate::AgentCommand;
use std::collections::HashSet;
use std::path::Path;
const MAX_DESCRIPTION: usize = 200;
/// Command directories nest this deep at most (`a/b/c.md` → `a:b:c`).
const MAX_DEPTH: usize = 4;
/// The commands Claude Code finds from `cwd`: nearest directory first, the user's last;
/// the first of a name wins.
pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
let mut out = Vec::new();
let mut seen = HashSet::new();
let mut add = |found: Vec<AgentCommand>| {
for c in found {
if seen.insert(c.name.clone()) {
out.push(c);
}
}
};
for dir in cwd.ancestors() {
if home.is_some_and(|h| h == dir) {
break;
}
add(in_dir(&dir.join(".claude")));
}
if let Some(home) = home {
add(in_dir(&home.join(".claude")));
}
out
}
/// `first` (the agent's own list) plus the commands of `more` it lacks.
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
let mut out = first;
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
out
}
fn in_dir(claude: &Path) -> Vec<AgentCommand> {
if !claude.is_dir() {
return Vec::new();
}
let mut out = skills(&claude.join("skills"));
commands(&claude.join("commands"), "", 0, &mut out);
out
}
fn skills(dir: &Path) -> Vec<AgentCommand> {
let mut out: Vec<AgentCommand> = sorted(dir)
.into_iter()
.filter_map(|path| {
let text = std::fs::read_to_string(path.join("SKILL.md")).ok()?;
let meta = Frontmatter::parse(&text);
if meta.get("user-invocable").is_some_and(|v| v.eq_ignore_ascii_case("false")) {
return None;
}
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
Some(meta.command(name, &text))
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>) {
if depth > MAX_DEPTH {
return;
}
for path in sorted(dir) {
let Some(name) = file_name(&path) else { continue };
if path.is_dir() {
commands(&path, &format!("{prefix}{name}:"), depth + 1, out);
} else if let Some(stem) = name.strip_suffix(".md")
&& let Ok(text) = std::fs::read_to_string(&path)
{
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
}
}
}
fn sorted(dir: &Path) -> Vec<std::path::PathBuf> {
let mut paths: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| file_name(p).is_some_and(|n| !n.starts_with('.')))
.collect();
paths.sort();
paths
}
fn file_name(path: &Path) -> Option<String> {
path.file_name()?.to_str().map(str::to_owned)
}
/// The `key: value` lines between the leading `---` fences (folded `>` / `|` values joined).
struct Frontmatter {
fields: Vec<(String, String)>,
/// Where the body starts.
body: usize,
}
impl Frontmatter {
fn parse(text: &str) -> Self {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut lines = text.split_inclusive('\n');
let mut offset = 0;
let mut fields: Vec<(String, String)> = Vec::new();
match lines.next() {
Some(first) if first.trim_end() == "---" => offset += first.len(),
_ => return Self { fields, body: 0 },
}
for line in lines {
offset += line.len();
let line = line.trim_end();
if line == "---" {
return Self { fields, body: offset };
}
if line.starts_with([' ', '\t']) {
if let Some((_, value)) = fields.last_mut() {
if !value.is_empty() {
value.push(' ');
}
value.push_str(line.trim());
}
continue;
}
if let Some((key, value)) = line.split_once(':') {
let value = value.trim();
let value = if matches!(value, ">" | "|" | ">-" | "|-") { "" } else { unquote(value) };
fields.push((key.trim().to_owned(), value.to_owned()));
}
}
// No closing fence: not frontmatter.
Self { fields: Vec::new(), body: 0 }
}
fn get(&self, key: &str) -> Option<&str> {
self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
/// `description`, else the body's first line.
fn command(&self, name: String, text: &str) -> AgentCommand {
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
let body = text.get(self.body..).unwrap_or_default();
body.lines()
.map(|l| l.trim().trim_start_matches('#').trim())
.find(|l| !l.is_empty())
.unwrap_or_default()
.to_owned()
});
AgentCommand {
name,
description: truncate(&description),
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
}
}
}
fn unquote(value: &str) -> &str {
for q in ['"', '\''] {
if let Some(inner) = value.strip_prefix(q).and_then(|v| v.strip_suffix(q)) {
return inner;
}
}
value
}
fn truncate(s: &str) -> String {
match s.char_indices().nth(MAX_DESCRIPTION) {
Some((i, _)) => format!("{}…", s[..i].trim_end()),
None => s.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &Path, text: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
fn names(cmds: &[AgentCommand]) -> Vec<&str> {
cmds.iter().map(|c| c.name.as_str()).collect()
}
fn cmd(name: &str, description: &str) -> AgentCommand {
AgentCommand { name: name.into(), description: description.into(), hint: None }
}
#[test]
fn project_and_user_skills_and_commands() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo = tmp.path().join("work").join("idea");
let skills = repo.join(".claude/skills");
write(
&skills.join("triage/SKILL.md"),
"---\nname: triage\ndescription: One pass over open issues and PRs\n---\n# Triage\n",
);
write(&skills.join("issue-fix/SKILL.md"), "---\nname: issue-fix\ndescription: \"Fix an issue\"\n---\n");
// No frontmatter name: the directory's.
write(&skills.join("verify/SKILL.md"), "---\ndescription: >\n Build and\n drive the app\n---\n");
write(&skills.join("hidden/SKILL.md"), "---\nname: hidden\nuser-invocable: false\n---\n");
write(&skills.join("no-skill-file/README.md"), "nothing");
write(&repo.join(".claude/commands/review.md"), "---\nargument-hint: <pr>\n---\nReview a pull request\n");
write(&repo.join(".claude/commands/git/commit.md"), "# Commit the staged changes\n");
// A parent directory's commands apply too.
write(&tmp.path().join("work/.claude/commands/parent.md"), "---\ndescription: from above\n---\n");
// The user's: shadowed by the project's skill of the same name.
write(&home.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: user triage\n---\n");
write(&home.join(".claude/skills/lint/SKILL.md"), "---\nname: lint\ndescription: user lint\n---\n");
write(&home.join(".claude/commands/brooks-review.md"), "---\ndescription: Review\n---\n");
let found = discover(&repo, Some(&home));
assert_eq!(
names(&found),
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
);
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
assert_eq!(by("triage").description, "One pass over open issues and PRs");
assert_eq!(by("issue-fix").description, "Fix an issue");
assert_eq!(by("verify").description, "Build and drive the app");
assert_eq!(by("git:commit").description, "Commit the staged changes");
assert_eq!(
(by("review").description.as_str(), by("review").hint.as_deref()),
("Review a pull request", Some("<pr>"))
);
}
#[test]
fn nothing_without_claude_dirs() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo = home.join("plain");
std::fs::create_dir_all(&repo).unwrap();
assert!(discover(&repo, Some(&home)).is_empty());
}
#[test]
fn walking_up_stops_at_home() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
// Above home: not the user's, and not this project's.
write(&tmp.path().join(".claude/commands/outside.md"), "x\n");
write(&home.join(".claude/commands/mine.md"), "x\n");
let repo = home.join("src/app");
std::fs::create_dir_all(&repo).unwrap();
assert_eq!(names(&discover(&repo, Some(&home))), ["mine"]);
}
#[test]
fn the_agents_entries_win() {
let merged = merge(
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
);
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
assert_eq!(merged[0].description, "from the agent");
}
#[test]
fn frontmatter_edge_cases() {
let f = Frontmatter::parse("no fence\n");
assert!(f.fields.is_empty());
let f = Frontmatter::parse("---\nname: x\nunterminated\n");
assert!(f.get("name").is_none());
let f = Frontmatter::parse("\u{feff}---\r\nname: 'quoted'\r\n---\r\nbody\r\n");
assert_eq!(f.get("name"), Some("quoted"));
assert_eq!(truncate(&"é".repeat(MAX_DESCRIPTION + 5)).chars().count(), MAX_DESCRIPTION + 1);
}
}
+49 -1
Переглянути файл
@@ -93,6 +93,10 @@ impl Handler for FakeAgent {
}
fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
manager_at(dir, &dir.join("models.json"), agent)
}
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
@@ -107,14 +111,54 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
project_root: dir.to_path_buf(),
store_dir: dir.join("store"),
worktrees_dir: dir.join("worktrees"),
models_file: dir.join("models.json"),
models_file: models_file.to_path_buf(),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
)
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
let (a, b, models) = (tmp.path().join("a"), tmp.path().join("b"), tmp.path().join("models.json"));
std::fs::create_dir_all(a.join(".claude/skills/triage")).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n").unwrap();
let names = |c: Vec<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
// Before any session: the skills on disk.
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]);
let task = mgr_a
.create(NewAgentTask {
prompt: text("Fix the lexer"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr_a, &task.id, AgentStatus::NeedsInput).await;
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["review", "triage"]);
assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]);
// Other agents get only what they offered.
assert!(mgr_a.commands("codex", None).await.unwrap().is_empty());
mgr_a.shutdown();
// Another project shares the settings but not the commands.
let mgr_b = manager_at(&b, &models, Arc::new(FakeAgent::default()));
let claude = mgr_b.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert_eq!(claude.config.len(), 2);
assert!(claude.commands.is_empty());
assert!(mgr_b.commands("claude", None).await.unwrap().is_empty());
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]);
}
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
for _ in 0..500 {
if mgr.task(id).unwrap().state.lock().task.status == status {
@@ -299,6 +343,9 @@ fn prompts_become_acp_content_blocks() {
blocks(&p, &[Some("fn a() {}".into())])[1],
json!({"type": "resource", "resource": {"uri": "file:///p/my%20file.rs", "text": "fn a() {}"}})
);
// A slash command goes through as typed: the agent runs it.
let skill = clean(text(" /triage dry-run\n")).unwrap();
assert_eq!(blocks(&skill, &[]), [json!({"type": "text", "text": "/triage dry-run"})]);
let old = upgrade(
json!({"task": {"id": "t"}, "entries": [{"type": "user", "text": "hi"}, {"type": "agent", "text": "a"}]}),
);
@@ -375,6 +422,7 @@ async fn real_claude_task_in_a_worktree() {
worktrees_dir: tmp.path().join("wt"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
);
+14 -12
Переглянути файл
@@ -14,7 +14,7 @@ use std::pin::Pin;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::process::{Child, Command};
pub const PORT_PLACEHOLDER: &str = "${port}";
@@ -457,17 +457,19 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
name: String,
sink: Arc<parking_lot::Mutex<Vec<String>>>,
) {
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
let mut buf = sink.lock();
if buf.len() >= 40 {
buf.remove(0);
}
buf.push(line);
}
});
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
push_line(&sink, line);
}));
}
/// Appends to the recent-output buffer of an adapter (the last 40 lines).
pub(crate) fn push_line(sink: &parking_lot::Mutex<Vec<String>>, line: String) {
let mut buf = sink.lock();
if buf.len() >= 40 {
buf.remove(0);
}
buf.push(line);
}
const LOOPBACK: &str = "127.0.0.1";
+21 -2
Переглянути файл
@@ -95,7 +95,21 @@ pub struct Connection {
impl Connection {
/// Starts reader/writer tasks over `reader`/`writer`. Must be called inside a Tokio runtime.
pub fn start<R, W>(reader: R, mut writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
pub fn start<R, W>(reader: R, writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_output(reader, writer, Box::new(|_| {}))
}
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
@@ -133,9 +147,14 @@ impl Connection {
let reader_inner = inner.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let mut noise = |line: &[u8]| {
let text = ide_jsonrpc::output::decode(line);
tracing::warn!("dap: output outside the protocol: {text}");
on_output(text);
};
loop {
let msg = tokio::select! {
msg = framing::read_message(&mut reader) => msg,
msg = framing::read_message_or_noise(&mut reader, &mut noise) => msg,
_ = reader_inner.cancel.cancelled() => break,
};
match msg {
+4 -1
Переглянути файл
@@ -681,7 +681,10 @@ impl DebugManager {
}
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let stderr = io.stderr.clone();
let (conn, incoming) = Connection::start(io.reader, io.writer);
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
self.sessions.lock().insert(id, session.clone());
+20
Переглянути файл
@@ -66,6 +66,23 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
host.project(window)?.lsp.goto(doc, pos, kind).await
}
/// Implementations of the symbols at `positions`, one list per position (the
/// editor's "is implemented / overridden" gutter markers). Cancel with
/// `cancel_request(request)`.
pub async fn lsp_implementations(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
host.end_request(request);
result
}
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
@@ -274,6 +291,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
"lsp_goto",
|c| async move { lsp_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await },
);
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add("lsp_references", |c| async move {
lsp_references(
+2
Переглянути файл
@@ -147,6 +147,7 @@ impl Host {
&bundles,
lombok.jar(),
);
ide_sdk::jdtls::launch_directly(cfg);
}
}));
}
@@ -284,6 +285,7 @@ impl Host {
.join(&project_key),
models_file: self.data_dir.join("agents").join("models.json"),
custom_agents_dir: Some(self.config_dir.join("agents")),
home_dir: dirs::home_dir(),
},
self.runtime.clone(),
));
+1
Переглянути файл
@@ -15,3 +15,4 @@ futures.workspace = true
parking_lot.workspace = true
thiserror.workspace = true
tracing.workspace = true
encoding_rs.workspace = true
+112 -46
Переглянути файл
@@ -6,7 +6,10 @@
//! * [`Peer`]: a JSON-RPC 2.0 endpoint with request/response correlation,
//! cancellation (`$/cancelRequest`) and handlers for server→client traffic.
pub mod output;
pub mod framing {
use crate::output::trim_line_end as trim_line;
use std::io::{Error, ErrorKind};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWrite, AsyncWriteExt};
@@ -21,6 +24,19 @@ pub mod framing {
/// matched case-insensitively, unknown headers (e.g. `Content-Type`) are
/// ignored, and both `\r\n` and bare `\n` line endings are accepted.
pub async fn read_message<R: AsyncBufRead + Unpin>(reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
read_message_or_noise(reader, &mut |line| {
tracing::warn!("jsonrpc: ignoring output outside the protocol: {}", crate::output::decode(line));
})
.await
}
/// [`read_message`] that hands lines which can't start a message header (a
/// launcher's or runtime's own output on stdout, in any encoding) to `on_noise`
/// and skips them, instead of failing on them.
pub async fn read_message_or_noise<R: AsyncBufRead + Unpin>(
reader: &mut R,
on_noise: &mut (dyn FnMut(&[u8]) + Send),
) -> std::io::Result<Option<Vec<u8>>> {
let mut content_length: Option<usize> = None;
let mut saw_any = false;
let mut line = Vec::with_capacity(64);
@@ -37,6 +53,11 @@ pub mod framing {
if n as u64 >= MAX_HEADER_LINE {
return Err(Error::new(ErrorKind::InvalidData, "header line too long"));
}
if !saw_any && header(&line).is_none() {
// Output without a final newline, then EOF (`pause` waiting for a key).
on_noise(trim_line(&line));
return Ok(None);
}
return Err(Error::new(ErrorKind::UnexpectedEof, "EOF inside message header"));
}
let text = trim_line(&line);
@@ -50,12 +71,18 @@ pub mod framing {
// Tolerate stray blank lines between messages.
continue;
}
saw_any = true;
let text = std::str::from_utf8(text).map_err(|_| Error::new(ErrorKind::InvalidData, "non-UTF-8 header"))?;
let Some((name, value)) = text.split_once(':') else {
return Err(Error::new(ErrorKind::InvalidData, format!("malformed header line: {text:?}")));
let Some((name, value)) = header(text) else {
if !saw_any {
on_noise(text);
continue;
}
return Err(Error::new(
ErrorKind::InvalidData,
format!("malformed header line: {:?}", crate::output::decode(text)),
));
};
if name.trim().eq_ignore_ascii_case("content-length") {
saw_any = true;
if name.eq_ignore_ascii_case("content-length") {
let len: usize = value
.trim()
.parse()
@@ -72,12 +99,12 @@ pub mod framing {
Ok(Some(body))
}
fn trim_line(line: &[u8]) -> &[u8] {
let mut end = line.len();
while end > 0 && matches!(line[end - 1], b'\n' | b'\r') {
end -= 1;
}
&line[..end]
/// `name: value` with a header-like name (a token, no spaces), else `None`.
fn header(line: &[u8]) -> Option<(&str, &str)> {
let text = std::str::from_utf8(trim_line(line)).ok()?;
let (name, value) = text.split_once(':')?;
let token = !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
token.then_some((name, value))
}
/// Writes one framed message and flushes once.
@@ -126,7 +153,7 @@ use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
use tokio::io::{AsyncRead, AsyncWrite, BufReader};
use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::sync::CancellationToken;
@@ -213,6 +240,8 @@ struct Inner {
/// Fired by [`Peer::kill`]; stops tasks and kills the child.
kill: CancellationToken,
pid: Option<u32>,
/// Fired when the reader task has stopped (EOF, error or kill).
reader_done: CancellationToken,
}
impl Inner {
@@ -258,10 +287,20 @@ pub enum Framing {
}
impl Framing {
async fn read<R: tokio::io::AsyncBufRead + Unpin>(self, reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
async fn read<R: tokio::io::AsyncBufRead + Unpin>(
self,
reader: &mut R,
on_noise: &mut (dyn FnMut(&[u8]) + Send),
) -> std::io::Result<Option<Vec<u8>>> {
match self {
Framing::ContentLength => framing::read_message(reader).await,
Framing::Lines => framing::read_line_message(reader).await,
Framing::ContentLength => framing::read_message_or_noise(reader, on_noise).await,
Framing::Lines => loop {
let Some(line) = framing::read_line_message(reader).await? else { return Ok(None) };
match line.iter().find(|b| !b.is_ascii_whitespace()) {
Some(b'{' | b'[') => return Ok(Some(line)),
_ => on_noise(&line),
}
},
}
}
@@ -273,6 +312,9 @@ impl Framing {
}
}
/// How long a process that exited is given for its last output to be read.
const OUTPUT_DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
/// A connected JSON-RPC peer. Cloning shares the connection.
#[derive(Clone)]
pub struct Peer {
@@ -290,8 +332,9 @@ impl Drop for KillOnDrop {
}
impl Peer {
/// Spawns `command` and speaks JSON-RPC over its stdio. Stderr lines are
/// forwarded to `tracing` and to `on_stderr`.
/// Spawns `command` and speaks JSON-RPC over its stdio. Stderr lines, and
/// stdout lines that are not part of a message (a launcher's own output), are
/// decoded (see [`output::decode`]) and forwarded to `tracing` and to `on_stderr`.
///
/// Must be called from within a Tokio runtime.
pub fn spawn(
@@ -332,42 +375,44 @@ impl Peer {
let stderr = child.stderr.take();
let pid = child.id();
let peer = Self::start(stdout, stdin, handler, false, pid, framing);
let on_output: Arc<dyn Fn(String) + Send + Sync> = Arc::from(on_stderr);
let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone()));
if let Some(stderr) = stderr {
let stderr_task = stderr.map(|stderr| {
let name = command.to_owned();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let text = String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_owned();
tracing::debug!(target: "ide_jsonrpc::stderr", "[{name}] {text}");
on_stderr(text);
}
}
}
});
}
tokio::spawn(output::read_lines(stderr, move |text| {
tracing::debug!(target: "ide_jsonrpc::stderr", "[{name}] {text}");
on_output(text);
}))
});
peer.supervise(child);
peer.supervise(child, stderr_task);
Ok(peer)
}
/// Kills `child` when the connection is torn down; closes the connection when it exits.
fn supervise(&self, mut child: tokio::process::Child) {
/// After an exit of its own, the connection is reported closed only once what the
/// process wrote last (stderr, stray stdout) has been read, so a crash report has it.
fn supervise(&self, mut child: tokio::process::Child, stderr_task: Option<tokio::task::JoinHandle<()>>) {
let inner = self.inner.clone();
tokio::spawn(async move {
let status = tokio::select! {
status = child.wait() => status.ok(),
let (status, killed) = tokio::select! {
status = child.wait() => (status.ok(), false),
_ = inner.kill.cancelled() => {
let _ = child.start_kill();
child.wait().await.ok()
(child.wait().await.ok(), true)
}
};
if !killed {
// Bounded: a grandchild may keep the pipes open.
let drained = async {
if let Some(task) = stderr_task {
let _ = task.await;
}
inner.reader_done.cancelled().await;
};
let _ = tokio::time::timeout(OUTPUT_DRAIN, drained).await;
}
inner.mark_closed(status.and_then(|s| s.code()));
});
}
@@ -381,7 +426,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, Framing::ContentLength)
Self::start(reader, writer, handler, true, None, Framing::ContentLength, None)
}
/// [`connect`](Self::connect) with the given wire framing.
@@ -390,7 +435,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, framing)
Self::start(reader, writer, handler, true, None, framing, None)
}
/// [`Peer::connect`] over streams served by `child` (a server reached over a socket
@@ -407,8 +452,8 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength);
peer.supervise(child);
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength, None);
peer.supervise(child, None);
peer
}
@@ -419,6 +464,7 @@ impl Peer {
close_on_eof: bool,
pid: Option<u32>,
framing: Framing,
on_noise: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Self
where
R: AsyncRead + Unpin + Send + 'static,
@@ -434,6 +480,7 @@ impl Peer {
closed,
kill: CancellationToken::new(),
pid,
reader_done: CancellationToken::new(),
});
// Writer: a single task drains the queue so messages never interleave.
@@ -491,20 +538,39 @@ impl Peer {
let inner = inner.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let seen_message = AtomicBool::new(false);
let mut noise = |line: &[u8]| {
let text = output::decode(line);
if seen_message.load(Ordering::Relaxed) {
tracing::debug!("jsonrpc: output outside the protocol: {text}");
} else {
tracing::warn!("jsonrpc: output before the first message: {text}");
}
if let Some(f) = &on_noise {
f(text);
}
};
loop {
let msg = tokio::select! {
msg = framing.read(&mut reader) => msg,
msg = framing.read(&mut reader, &mut noise) => msg,
_ = inner.kill.cancelled() => break,
};
match msg {
Ok(Some(body)) => dispatch(&inner, &handler, &notif_tx, &body),
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &handler, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
tracing::warn!("jsonrpc read failed: {e}");
if let Some(f) = &on_noise {
f(format!("invalid protocol output: {e}"));
}
break;
}
}
}
inner.reader_done.cancel();
inner.close_transport();
if close_on_eof || inner.kill.is_cancelled() {
inner.mark_closed(None);
+136
Переглянути файл
@@ -0,0 +1,136 @@
//! Text a server process writes outside the protocol: stderr, and stray stdout
//! lines (a launcher script's or the runtime's own messages) before a message.
//!
//! Such text is often not UTF-8: on Windows, `cmd.exe`, batch files and native
//! launchers write in the system code page (GBK on a Chinese system).
use encoding_rs::Encoding;
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
/// `bytes` as text: UTF-8 when valid, else (on Windows) the console / ANSI code
/// page, else UTF-8 with replacement characters.
pub fn decode(bytes: &[u8]) -> String {
decode_with(bytes, &native_code_pages())
}
/// [`decode`] trying the given Windows code pages, in order, after UTF-8.
pub fn decode_with(bytes: &[u8], code_pages: &[u32]) -> String {
if let Ok(s) = std::str::from_utf8(bytes) {
return s.to_owned();
}
for enc in code_pages.iter().filter_map(|&cp| encoding_for_code_page(cp)) {
let (text, had_errors) = enc.decode_without_bom_handling(bytes);
if !had_errors {
return text.into_owned();
}
}
String::from_utf8_lossy(bytes).into_owned()
}
/// The `encoding_rs` encoding of a Windows code page, when it has one.
pub fn encoding_for_code_page(cp: u32) -> Option<&'static Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
54936 => encoding_rs::GB18030,
950 => encoding_rs::BIG5,
932 => encoding_rs::SHIFT_JIS,
949 => encoding_rs::EUC_KR,
866 => encoding_rs::IBM866,
874 => encoding_rs::WINDOWS_874,
1250 => encoding_rs::WINDOWS_1250,
1251 => encoding_rs::WINDOWS_1251,
1252 => encoding_rs::WINDOWS_1252,
1253 => encoding_rs::WINDOWS_1253,
1254 => encoding_rs::WINDOWS_1254,
1255 => encoding_rs::WINDOWS_1255,
1256 => encoding_rs::WINDOWS_1256,
1257 => encoding_rs::WINDOWS_1257,
1258 => encoding_rs::WINDOWS_1258,
20866 => encoding_rs::KOI8_R,
21866 => encoding_rs::KOI8_U,
_ => return None,
})
}
/// The OEM (console) and ANSI code pages: console programs and batch files write in
/// the former, GUI-subsystem and Java launchers in the latter.
#[cfg(windows)]
fn native_code_pages() -> [u32; 2] {
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetOEMCP() -> u32;
fn GetACP() -> u32;
}
// SAFETY: argument-less Win32 queries.
unsafe { [GetOEMCP(), GetACP()] }
}
#[cfg(not(windows))]
fn native_code_pages() -> [u32; 0] {
[]
}
/// Removes a trailing `\n` / `\r\n` (and stray `\r`s before it).
pub fn trim_line_end(line: &[u8]) -> &[u8] {
let mut end = line.len();
while end > 0 && matches!(line[end - 1], b'\n' | b'\r') {
end -= 1;
}
&line[..end]
}
/// Reads `stream` to its end, calling `on_line` with each line decoded by [`decode`].
/// Unlike `AsyncBufReadExt::lines`, a line that is not UTF-8 does not end the stream.
pub async fn read_lines<R: AsyncRead + Unpin>(stream: R, mut on_line: impl FnMut(String)) {
let mut reader = BufReader::new(stream);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => on_line(decode(trim_line_end(&line))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `请按任意键继续. . .`, what `pause` prints on a Chinese Windows (code page 936).
const PAUSE_GBK: &[u8] = b"\xc7\xeb\xb0\xb4\xc8\xce\xd2\xe2\xbc\xfc\xbc\xcc\xd0\xf8. . .";
#[test]
fn utf8_passes_through() {
assert_eq!(decode_with("héllo 中文".as_bytes(), &[936]), "héllo 中文");
}
#[test]
fn code_page_text_is_decoded() {
assert_eq!(decode_with(PAUSE_GBK, &[936, 936]), "请按任意键继续. . .");
// The first code page that decodes cleanly wins.
assert_eq!(decode_with(PAUSE_GBK, &[437, 936]), "请按任意键继续. . .");
assert_eq!(decode_with(b"caf\xe9", &[1252]), "café");
}
#[test]
fn undecodable_text_is_lossy_utf8() {
let out = decode_with(PAUSE_GBK, &[]);
assert!(out.contains('\u{FFFD}') && out.ends_with(". . ."), "{out}");
assert_eq!(decode_with(b"a\xffb", &[65001]), "a\u{FFFD}b");
}
#[tokio::test]
async fn read_lines_survives_non_utf8_lines() {
let mut input = b"first\r\n".to_vec();
input.extend_from_slice(b"\xff\xfe broken\n");
input.extend_from_slice(b"last");
let mut lines = Vec::new();
read_lines(&input[..], |l| lines.push(l)).await;
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "first");
assert!(lines[1].ends_with(" broken"));
assert_eq!(lines[2], "last");
}
}
+67
Переглянути файл
@@ -44,6 +44,46 @@ mod framing_tests {
assert!(read_all(long.as_bytes()).await.is_err(), "header too long");
}
async fn read_with_noise(input: &[u8]) -> (std::io::Result<Vec<Vec<u8>>>, Vec<String>) {
let mut reader = BufReader::new(input);
let mut noise = Vec::new();
let mut out = Vec::new();
let result = loop {
match read_message_or_noise(&mut reader, &mut |l| noise.push(crate::output::decode(l))).await {
Ok(Some(msg)) => out.push(msg),
Ok(None) => break Ok(out),
Err(e) => break Err(e),
}
};
(result, noise)
}
#[tokio::test]
async fn output_between_messages_is_reported_and_skipped() {
let input = b"OpenJDK 64-Bit Server VM warning: Sharing is only supported\r\n\xc7\xeb\xb0\xb4 \xff\n\
Content-Length: 2\r\n\r\n{}Picked up JAVA_TOOL_OPTIONS: -Dx\nContent-Length: 4\r\n\r\nnull";
let (msgs, noise) = read_with_noise(input).await;
assert_eq!(msgs.unwrap(), vec![b"{}".to_vec(), b"null".to_vec()]);
assert_eq!(noise.len(), 3, "{noise:?}");
assert_eq!(noise[0], "OpenJDK 64-Bit Server VM warning: Sharing is only supported");
assert_eq!(noise[2], "Picked up JAVA_TOOL_OPTIONS: -Dx");
}
#[tokio::test]
async fn unterminated_output_then_eof_is_reported() {
// `pause` prints its prompt without a newline and the process is gone.
let (msgs, noise) = read_with_noise(b"Press any key to continue . . . ").await;
assert!(msgs.unwrap().is_empty());
assert_eq!(noise, ["Press any key to continue . . . "]);
}
#[tokio::test]
async fn garbage_inside_a_header_is_still_an_error() {
let (msgs, _) = read_with_noise(b"Content-Length: 2\r\nnot a header\r\n\r\n{}").await;
let e = msgs.unwrap_err();
assert!(e.to_string().contains("not a header"), "{e}");
}
#[tokio::test]
async fn handles_partial_reads() {
let (mut client, server) = tokio::io::duplex(4);
@@ -351,3 +391,30 @@ async fn kill_terminates_process() {
peer.kill();
tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap();
}
/// A launcher that prints in the system code page and exits before speaking the
/// protocol: `jdtls.bat` on a Chinese Windows when its Python step fails, then
/// `pause` prints `请按任意键继续. . .` in GBK on stdout.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn output_before_the_handshake_is_reported_not_dropped() {
let lines = Arc::new(Mutex::new(Vec::new()));
let sink = lines.clone();
let script = r"printf 'python: not found\n' >&2; printf '\307\353\260\264\310\316\322\342\274\374\274\314\320\370. . .\r\n'; exit 1";
let peer = Peer::spawn(
"sh",
&["-c".into(), script.into()],
&std::env::temp_dir(),
&[],
Arc::new(NoopHandler),
Box::new(move |l| sink.lock().push(l)),
)
.unwrap();
let init = peer.request::<_, Value>("initialize", json!({}), None).await;
assert!(matches!(init, Err(RpcError::Closed)), "{init:?}");
assert_eq!(tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap(), Some(1));
// Everything the process wrote is in by the time the close is reported.
let lines = lines.lock().clone();
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
}
+58 -12
Переглянути файл
@@ -29,6 +29,7 @@ pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
use caps::{ServerCaps, SyncKind};
use convert::{Conv, lsp_range};
use futures::StreamExt;
use futures::future::join_all;
use ide_core::error::ErrorKind;
use ide_core::{
@@ -228,6 +229,18 @@ impl<T> Outcome<T> {
}
}
/// Implementation requests of one line-marker pass in flight at once.
const IMPLEMENTATION_CONCURRENCY: usize = 4;
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
match kind {
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
GotoKind::Declaration => ("textDocument/declaration", "declarationProvider", "go to declaration"),
GotoKind::TypeDefinition => ("textDocument/typeDefinition", "typeDefinitionProvider", "go to type definition"),
GotoKind::Implementation => ("textDocument/implementation", "implementationProvider", "go to implementation"),
}
}
impl LspManager {
pub fn new(project_root: PathBuf, languages: Arc<LanguageRegistry>, sink: SharedSink) -> Arc<Self> {
Self::with_launcher(project_root, languages, sink, Arc::new(ProcessLauncher))
@@ -406,6 +419,10 @@ impl LspManager {
}
Err(e) => {
tracing::warn!("{}: initialize failed: {e}", server.name);
if matches!(e, RpcError::Closed) {
// The server is exiting: let it, so the crash report has its exit code and last output.
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
}
peer.kill();
// Treated like a crash so transient failures retry with backoff.
self.spawn_supervisor(server, generation, peer);
@@ -921,25 +938,54 @@ impl LspManager {
}
pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let (method, cap, feature) = match kind {
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
GotoKind::Declaration => ("textDocument/declaration", "declarationProvider", "go to declaration"),
GotoKind::TypeDefinition => {
("textDocument/typeDefinition", "typeDefinitionProvider", "go to type definition")
}
GotoKind::Implementation => {
("textDocument/implementation", "implementationProvider", "go to implementation")
}
};
let (method, cap, feature) = goto_method(kind);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, None).await
}
/// Implementations of the symbols at each of `positions` (gutter line
/// markers), one list per position in order. A position whose request
/// fails gets an empty list; when every one fails, the first error is
/// returned (an unavailable server is not "no implementations").
pub async fn implementations(
&self,
doc: DocId,
positions: &[Pos],
cancel: CancellationToken,
) -> IdeResult<Vec<Vec<Location>>> {
let (method, cap, feature) = goto_method(GotoKind::Implementation);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
let requests = positions.iter().copied().map(|pos| {
let (snap, targets, cancel) = (&snap, &targets, &cancel);
async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await }
});
let results: Vec<IdeResult<Vec<Location>>> =
futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if !results.is_empty() && results.iter().all(Result::is_err) {
return Err(results.into_iter().find_map(Result::err).expect("all failed"));
}
Ok(results.into_iter().map(Result::unwrap_or_default).collect())
}
async fn goto_in(
&self,
snap: &DocSnap,
targets: &[Target],
method: &str,
pos: Pos,
cancel: Option<&CancellationToken>,
) -> IdeResult<Vec<Location>> {
let mut out = Outcome::new();
for t in &targets {
for t in targets {
match self
.call::<_, Option<lsp::GotoDefinitionResponse>>(
t,
method,
snap.pos_params(t, pos),
None,
cancel,
DEFAULT_TIMEOUT,
)
.await
+2 -7
Переглянути файл
@@ -14,7 +14,7 @@ use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::io::AsyncRead;
pub(crate) const PORT_PLACEHOLDER: &str = "${port}";
pub(crate) const ROOT_PLACEHOLDER: &str = "${root}";
@@ -157,12 +157,7 @@ pub(crate) fn connect_or_start(
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(stream: R, on_line: Arc<dyn Fn(String) + Send + Sync>) {
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
on_line(line);
}
});
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| on_line(line)));
}
#[cfg(test)]
+33
Переглянути файл
@@ -792,6 +792,39 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
}
#[tokio::test(flavor = "multi_thread")]
async fn implementations_per_position() {
let caps = json!({ "textDocumentSync": 1, "implementationProvider": true });
let h = harness(&[("mock", caps)]);
let (doc, path) = h.open("a.mk", "iface A {}\nimpl B {}\nimpl C {}\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let uri = uri_of(&path);
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
if method != "textDocument/implementation" {
return None;
}
// Line 0 is implemented on lines 1 and 2; nothing else is.
Some(match params["position"]["line"].as_u64() {
Some(0) => json!([
{ "uri": uri, "range": {"start":{"line":1,"character":5},"end":{"line":1,"character":6}} },
{ "uri": uri, "range": {"start":{"line":2,"character":5},"end":{"line":2,"character":6}} },
]),
_ => json!([]),
})
}));
let found = h.mgr.implementations(doc, &[Pos::new(0, 6), Pos::new(1, 5)], Default::default()).await.unwrap();
assert_eq!(found.len(), 2);
assert_eq!(found[0].iter().map(|l| l.range.start.line).collect::<Vec<_>>(), vec![1, 2]);
assert!(found[1].is_empty());
assert!(h.mgr.implementations(doc, &[], Default::default()).await.unwrap().is_empty());
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
let err = h.mgr.implementations(doc, &[Pos::new(0, 6)], cancel).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn navigation_rename_format_and_decorations() {
let h = harness(&[("mock", incremental_caps())]);
+67
Переглянути файл
@@ -0,0 +1,67 @@
//! A stdio server that dies before the handshake: what it printed (on stderr, and
//! on stdout outside the protocol, in any encoding) must reach the crash message.
#![cfg(unix)]
use ide_core::event::ServerStatus;
use ide_core::{DocId, IdeEvent, LanguageId};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
use ide_lsp::{LspManager, ProcessLauncher};
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
/// Shaped like `jdtls.bat` on a Chinese Windows whose Python step fails: an error on
/// stderr, then `pause` prompting in GBK on stdout (`请按任意键继续. . .`), reading one
/// key from stdin, exiting with an error.
const LAUNCHER: &str = r#"#!/bin/sh
echo "'python' is not recognized as an internal or external command" >&2
printf '\307\353\260\264\310\316\322\342\274\374\274\314\320\370. . . '
head -c 1 >/dev/null
printf '\r\n'
exit 1
"#;
#[tokio::test(flavor = "multi_thread")]
async fn crash_before_the_handshake_reports_what_the_server_printed() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let script = root.join("fake-jdtls");
std::fs::write(&script, LAUNCHER).unwrap();
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let server: LanguageServerConfig =
toml::from_str(&format!("name = \"fake\"\ncommand = {:?}", script.to_string_lossy())).unwrap();
let lang: LanguageConfig = toml::from_str("id = \"java\"\nname = \"Java\"\nextensions = [\"java\"]").unwrap();
let lang = LanguageConfig { language_servers: vec![server], ..lang };
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let mgr = LspManager::with_launcher(root.clone(), registry, sink, Arc::new(ProcessLauncher));
let path = root.join("A.java");
std::fs::write(&path, "class A {}\n").unwrap();
mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class A {}\n");
let crashed = || {
events.lock().iter().find_map(|e| match e {
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
_ => None,
})
};
let mut message = None;
for _ in 0..1000 {
message = crashed();
if message.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let message = message.expect("the server is reported as crashed");
assert!(message.starts_with("fake exited with code 1:\n"), "{message}");
assert!(message.contains("'python' is not recognized"), "{message}");
// The stdout prompt, decoded (code page 936 on Windows, lossily elsewhere).
assert!(message.contains(". . ."), "{message}");
mgr.shutdown_all().await;
}
+358
Переглянути файл
@@ -0,0 +1,358 @@
//! Starting jdtls on the JVM directly instead of through its launcher scripts.
//!
//! The release ships `bin/jdtls` (Python), `bin/jdtls.py` and, for Windows,
//! `bin/jdtls.bat`, which runs `python` and then `pause`. Each needs a Python
//! interpreter; without one the batch file prints `pause`'s prompt in the console
//! code page and exits. The command line the script builds is reproduced here, so
//! only a JDK is needed.
use crate::java::{Jdk, jdk_at};
use ide_language::LanguageServerConfig;
use std::path::{Path, PathBuf};
/// The parts of a jdtls install the JVM is started with.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JdtlsInstall {
/// `plugins/org.eclipse.equinox.launcher_<version>.jar`.
pub launcher: PathBuf,
/// The shared, read-only OSGi configuration for this platform (`config_win`, `config_mac_arm`…).
pub config: PathBuf,
}
/// Names of the release's launcher scripts; anything else is the user's own wrapper.
const SCRIPTS: &[&str] = &["jdtls", "jdtls.bat", "jdtls.cmd", "jdtls.py"];
/// The directory with `plugins/` of the jdtls `executable` belongs to (`<home>/bin/jdtls`,
/// or Homebrew's `<prefix>/bin/jdtls` wrapper with the install under `libexec`).
pub fn jdtls_home(executable: &Path) -> Option<PathBuf> {
let exe = dunce::canonicalize(executable).ok()?;
let home = exe.parent()?.parent()?;
[home.to_path_buf(), home.join("libexec")].into_iter().find(|h| h.join("plugins").is_dir())
}
/// Shared configuration directories to try, best first, for `os` / `arch`
/// (`std::env::consts` values), as the release names them.
pub fn config_dir_names(os: &str, arch: &str) -> Vec<String> {
let base = match os {
"windows" => "config_win",
"macos" => "config_mac",
_ => "config_linux",
};
let mut names = Vec::new();
if arch == "aarch64" {
names.push(format!("{base}_arm"));
}
names.push(base.to_owned());
names
}
/// The launcher jar and configuration of the install at `home`, when it has the
/// release layout.
pub fn find_install(home: &Path, os: &str, arch: &str) -> Option<JdtlsInstall> {
let plugins = home.join("plugins");
let unversioned = plugins.join("org.eclipse.equinox.launcher.jar");
let launcher = if unversioned.is_file() {
unversioned
} else {
let mut jars: Vec<PathBuf> = std::fs::read_dir(&plugins)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
// Not the native fragments (`org.eclipse.equinox.launcher.win32.win32.x86_64_…`).
n.starts_with("org.eclipse.equinox.launcher_") && n.ends_with(".jar")
})
})
.collect();
jars.sort();
jars.pop()?
};
let config =
config_dir_names(os, arch).into_iter().map(|n| home.join(n)).find(|d| d.join("config.ini").is_file())?;
Some(JdtlsInstall { launcher, config })
}
/// Arguments meant for the launcher script, sorted out.
#[derive(Debug, Default, PartialEq, Eq)]
struct ScriptArgs {
jvm: Vec<String>,
java: Option<String>,
data: Option<String>,
rest: Vec<String>,
}
fn split_script_args(args: &[String]) -> ScriptArgs {
let mut out = ScriptArgs::default();
let mut it = args.iter();
while let Some(a) = it.next() {
if let Some(v) = a.strip_prefix("--jvm-arg=") {
out.jvm.push(v.to_owned());
} else if a == "--jvm-arg" {
out.jvm.extend(it.next().cloned());
} else if let Some(v) = a.strip_prefix("--java-executable=") {
out.java = Some(v.to_owned());
} else if a == "--java-executable" {
out.java = it.next().cloned();
} else if a == "-data" {
out.data = it.next().cloned();
} else if let Some(v) = a.strip_prefix("-data=") {
out.data = Some(v.to_owned());
} else if a != "--validate-java-version" && a != "--no-validate-java-version" {
out.rest.push(a.clone());
}
}
out
}
/// The JVM command line `bin/jdtls` builds: its system properties and module
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`.
pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec<String> {
let parsed = split_script_args(script_args);
let mut args: Vec<String> = Vec::new();
if java_major >= 24 {
args.extend(["-Djdk.xml.maxGeneralEntitySizeLimit=0".into(), "-Djdk.xml.totalEntitySizeLimit=0".into()]);
}
args.extend(
[
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Dosgi.checkConfiguration=true",
]
.map(String::from),
);
args.push(format!("-Dosgi.sharedConfiguration.area={}", install.config.to_string_lossy()));
args.extend(
[
"-Dosgi.sharedConfiguration.area.readOnly=true",
"-Dosgi.configuration.cascaded=true",
"-Xms1G",
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
]
.map(String::from),
);
args.extend(parsed.jvm);
args.extend(["-jar".to_owned(), install.launcher.to_string_lossy().into_owned()]);
if let Some(data) = parsed.data {
args.extend(["-data".to_owned(), data]);
}
args.extend(parsed.rest);
args
}
/// Rewrites a jdtls launch (`command` = the release's script, `JAVA_HOME` chosen by
/// [`crate::java::configure_jdtls`]) to start `java` directly. Left unchanged when the
/// command is someone's own wrapper, the install has another layout, or no JDK is set
/// (the launch then reports why).
pub fn launch_directly(cfg: &mut LanguageServerConfig) {
if cfg.name != "jdtls" || cfg.launch_error.is_some() {
return;
}
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
let Some(script) = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s)) else { return };
let is_script = dunce::canonicalize(&script)
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase()))
.is_some_and(|n| SCRIPTS.contains(&n.as_str()));
if !is_script {
return;
}
let Some(install) =
jdtls_home(&script).and_then(|h| find_install(&h, std::env::consts::OS, std::env::consts::ARCH))
else {
return;
};
let own_java = split_script_args(&cfg.args).java.map(PathBuf::from);
let jdk: Option<(PathBuf, Jdk)> = match own_java {
Some(java) => java.parent().and_then(Path::parent).and_then(jdk_at).map(|jdk| (java, jdk)),
None => cfg.env.get("JAVA_HOME").and_then(|h| jdk_at(Path::new(h))).map(|jdk| {
let java = crate::java::java_executable(&jdk.home);
(java, jdk)
}),
};
let Some((java, jdk)) = jdk.filter(|(java, _)| java.is_file()) else { return };
cfg.args = java_args(&install, jdk.major, &cfg.args);
cfg.command = java.to_string_lossy().into_owned();
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
/// What [`crate::java::configure_jdtls`] and the settings pass to the script.
fn script_args(data: &str, agent: &str) -> Vec<String> {
strings(&[
"--jvm-arg=-Xmx2G",
"-data",
data,
"--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false",
&format!("--jvm-arg=-javaagent:{agent}"),
])
}
#[test]
fn windows_command_line() {
// The reporter's layout: a managed install under %APPDATA%, JDK 25, a user name with a space and CJK.
let tools = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\jdtls";
let install = JdtlsInstall {
launcher: PathBuf::from(format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar")),
config: PathBuf::from(format!(r"{tools}\config_win")),
};
let data = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\lsp\jdtls\0123456789abcdef";
let agent = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\lombok\bin\lombok.jar";
let args = java_args(&install, 25, &script_args(data, agent));
assert_eq!(
args,
strings(&[
"-Djdk.xml.maxGeneralEntitySizeLimit=0",
"-Djdk.xml.totalEntitySizeLimit=0",
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Dosgi.checkConfiguration=true",
&format!(r"-Dosgi.sharedConfiguration.area={tools}\config_win"),
"-Dosgi.sharedConfiguration.area.readOnly=true",
"-Dosgi.configuration.cascaded=true",
"-Xms1G",
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-Xmx2G",
"-Djava.import.generatesMetadataFilesAtProjectRoot=false",
&format!("-javaagent:{agent}"),
"-jar",
&format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"),
"-data",
data,
])
);
}
#[test]
fn unix_command_line() {
let install = JdtlsInstall {
launcher: PathBuf::from(
"/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar",
),
config: PathBuf::from("/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm"),
};
let args = java_args(
&install,
21,
&strings(&["--jvm-arg", "-Dlog.level=ALL", "--no-validate-java-version", "-data", "/d", "--extra"]),
);
assert_eq!(args[0], "-Declipse.application=org.eclipse.jdt.ls.core.id1", "no JDK 24+ flags on 21");
assert!(args.contains(&"-Dosgi.sharedConfiguration.area=/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm".to_owned()));
assert_eq!(
args[args.len() - 7..],
strings(&[
"java.base/java.lang=ALL-UNNAMED",
"-Dlog.level=ALL",
"-jar",
"/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar",
"-data",
"/d",
"--extra",
])
);
assert!(!args.iter().any(|a| a.contains("validate-java-version")));
}
#[test]
fn config_dirs_per_platform() {
assert_eq!(config_dir_names("windows", "x86_64"), ["config_win"]);
assert_eq!(config_dir_names("windows", "aarch64"), ["config_win_arm", "config_win"]);
assert_eq!(config_dir_names("macos", "aarch64"), ["config_mac_arm", "config_mac"]);
assert_eq!(config_dir_names("linux", "x86_64"), ["config_linux"]);
}
#[test]
fn finds_the_launcher_and_configuration_of_a_release_layout() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
std::fs::create_dir_all(home.join("plugins")).unwrap();
for jar in [
"org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.1300.v20250331-1702.jar",
"org.eclipse.jdt.ls.core_1.50.0.jar",
] {
std::fs::write(home.join("plugins").join(jar), "").unwrap();
}
assert_eq!(find_install(home, "windows", "x86_64"), None, "no configuration");
for config in ["config_win", "config_mac", "config_mac_arm"] {
std::fs::create_dir_all(home.join(config)).unwrap();
std::fs::write(home.join(config).join("config.ini"), "").unwrap();
}
let win = find_install(home, "windows", "x86_64").unwrap();
assert_eq!(win.launcher, home.join("plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"));
assert_eq!(win.config, home.join("config_win"));
assert_eq!(find_install(home, "windows", "aarch64").unwrap().config, home.join("config_win"));
assert_eq!(find_install(home, "macos", "aarch64").unwrap().config, home.join("config_mac_arm"));
}
#[cfg(unix)]
#[test]
fn rewrites_the_release_script_to_java() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("jdtls");
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::create_dir_all(home.join("plugins")).unwrap();
std::fs::write(home.join("plugins/org.eclipse.equinox.launcher_1.7.0.jar"), "").unwrap();
for config in config_dir_names(std::env::consts::OS, std::env::consts::ARCH) {
std::fs::create_dir_all(home.join(&config)).unwrap();
std::fs::write(home.join(&config).join("config.ini"), "").unwrap();
}
let script = home.join("bin/jdtls");
std::fs::write(&script, "#!/usr/bin/env python3\n").unwrap();
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let jdk = dir.path().join("jdk-25");
std::fs::create_dir_all(jdk.join("bin")).unwrap();
std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(jdk.join("bin/java"), "").unwrap();
let cfg = |command: &str| -> LanguageServerConfig {
let mut cfg: LanguageServerConfig =
toml::from_str(&format!("name = \"jdtls\"\ncommand = {command:?}")).unwrap();
cfg.env.insert("PATH".into(), home.join("bin").to_string_lossy().into_owned());
cfg.env.insert("JAVA_HOME".into(), jdk.to_string_lossy().into_owned());
cfg.args = strings(&["-data", "/ws", "--jvm-arg=-Xmx1G"]);
cfg
};
let mut direct = cfg("jdtls");
launch_directly(&mut direct);
let java = dunce::canonicalize(&jdk).unwrap().join("bin/java");
assert_eq!(Path::new(&direct.command), java);
assert_eq!(direct.args[0], "-Djdk.xml.maxGeneralEntitySizeLimit=0");
assert!(direct.args.iter().any(|a| a == "-Xmx1G"));
let jar = direct.args.iter().position(|a| a == "-jar").unwrap();
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"]));
// Someone's own wrapper, or no usable JDK: the command is kept.
std::fs::write(home.join("bin/my-jdtls"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(home.join("bin/my-jdtls"), std::os::unix::fs::PermissionsExt::from_mode(0o755))
.unwrap();
let mut own = cfg("my-jdtls");
launch_directly(&mut own);
assert_eq!(own.command, "my-jdtls");
let mut no_jdk = cfg("jdtls");
no_jdk.env.remove("JAVA_HOME");
launch_directly(&mut no_jdk);
assert_eq!(no_jdk.command, "jdtls");
let mut failing = cfg("jdtls");
failing.launch_error = Some("needs JDK 25".into());
launch_directly(&mut failing);
assert_eq!(failing.command, "jdtls");
}
}
+1
Переглянути файл
@@ -10,6 +10,7 @@ mod discover;
mod exec;
pub mod godot;
pub mod java;
pub mod jdtls;
pub mod libraries;
pub mod locate;
pub mod lombok;
+15 -1
Переглянути файл
@@ -1,6 +1,6 @@
use crate::state::AppState;
use ide_agent::{
AgentChange, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath};
use std::sync::Arc;
@@ -27,6 +27,20 @@ pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult<
Ok(state.project(window.label())?.agents.providers())
}
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
/// before a task exists (skills and custom commands on disk included).
#[tauri::command]
#[specta::specta]
pub async fn agent_commands(
window: Window,
state: State<'_, AppState>,
provider: String,
task: Option<String>,
) -> IdeResult<Vec<AgentCommand>> {
let project = state.project(window.label())?;
project.agents.commands(&provider, task.as_deref()).await
}
#[tauri::command]
#[specta::specta]
pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentEntry>> {
+15
Переглянути файл
@@ -80,6 +80,21 @@ pub async fn lsp_goto(
ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await
}
/// Implementations of the symbols at `positions`, one list per position (the
/// editor's "is implemented / overridden" gutter markers). Cancel with
/// `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn lsp_implementations(
window: Window,
state: State<'_, AppState>,
request: RequestId,
doc: DocId,
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
ide_host::ops::lsp::lsp_implementations(&state.host, window.label(), request, doc, positions).await
}
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. Cached per project.
#[tauri::command]
+2
Переглянути файл
@@ -73,6 +73,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
app::log_frontend,
agent::agent_subscribe,
agent::agent_providers,
agent::agent_commands,
agent::agent_entries,
agent::agent_create,
agent::agent_prompt,
@@ -154,6 +155,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
lsp::lsp_hover,
lsp::lsp_signature_help,
lsp::lsp_goto,
lsp::lsp_implementations,
lsp::lsp_virtual_document,
lsp::lsp_references,
lsp::lsp_document_highlights,
+11
Переглянути файл
@@ -56,6 +56,11 @@ export const commands = {
/** Streams the project's agent events to this window; returns every task. */
agentSubscribe: (onEvent: Channel<AgentEvent>) => typedError<AgentTask[], IdeError>(__TAURI_INVOKE("agent_subscribe", { onEvent })),
agentProviders: () => typedError<AgentProvider[], IdeError>(__TAURI_INVOKE("agent_providers")),
/**
* Slash commands to complete for `provider` in `task`'s directory, or in the project
* before a task exists (skills and custom commands on disk included).
*/
agentCommands: (provider: string, task: string | null) => typedError<AgentCommand[], IdeError>(__TAURI_INVOKE("agent_commands", { provider, task })),
agentEntries: (task: string) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task })),
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
@@ -282,6 +287,12 @@ export const commands = {
activeParameter: number,
} | null, IdeError>(__TAURI_INVOKE("lsp_signature_help", { doc, pos })),
lspGoto: (doc: DocId, pos: Pos, kind: GotoKind) => typedError<Location[], IdeError>(__TAURI_INVOKE("lsp_goto", { doc, pos, kind })),
/**
* Implementations of the symbols at `positions`, one list per position (the
* editor's "is implemented / overridden" gutter markers). Cancel with
* `cancel_request(request)`.
*/
lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError<Location[][], IdeError>(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })),
/**
* Contents of a virtual document (library class, decompiled code) that a
* language server returned a non-`file:` URI for. Cached per project.
+22
Переглянути файл
@@ -283,6 +283,28 @@ export function contributedAt<T>(state: EditorState, key: string, pos: number):
return state.languageDataAt<T>(key, pos)[0];
}
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */
export interface ContributedIntention {
id: string;
label: string;
icon?: string;
run: () => void | Promise<void>;
}
/** Providers of `ContributedIntention`s for the caret of `view` (cheap and synchronous: also drives the light bulb). */
export const intentionProviders = Facet.define<(view: EditorView) => ContributedIntention[]>();
export function contributedIntentions(view: EditorView): ContributedIntention[] {
return view.state.facet(intentionProviders).flatMap((p) => {
try {
return p(view);
} catch (e) {
console.error("intention provider failed", e);
return [];
}
});
}
// --- navigation history ------------------------------------------------------
/** One entry of the Back/Forward (⌘[ / ⌘]) history. 0-based line, UTF-16 col. */
+2
Переглянути файл
@@ -402,6 +402,8 @@ const noSurround = EditorView.inputHandler.of((view, from, to, text) => {
export const GUTTER_ICON_GROUPS = [
{ id: "run", gutterClass: "cm-openidea-gutter-run" },
{ id: "bookmarks", gutterClass: "cm-openidea-bookmarks" },
{ id: "inheritors", gutterClass: "cm-openidea-gutter-lineMarkers" },
{ id: "supers", gutterClass: "cm-openidea-gutter-lineMarkers" },
] as const;
export interface PrefsOptions {
+3
Переглянути файл
@@ -57,6 +57,9 @@ export const openideaTheme = EditorView.theme({
boxShadow: "inset 0 1px 0 var(--border-method-separator, var(--border))",
},
"&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": { display: "none" },
// Inheritance markers share one gutter: each group hides its own icons.
"&.cm-openidea-hideGutter-inheritors :is(.cm-openidea-lineMarker-implemented, .cm-openidea-lineMarker-overridden), &.cm-openidea-hideGutter-supers :is(.cm-openidea-lineMarker-implementing, .cm-openidea-lineMarker-overriding)":
{ display: "none" },
// Distraction Free Mode: no gutter; the text column (n chars + line padding) is centred.
"&.cm-openidea-distractionFree": { "--openidea-df-width": "calc(var(--openidea-df-columns, 80) * 1ch + 22px)" },
"&.cm-openidea-distractionFree .cm-gutters": { display: "none" },
+3 -1
Переглянути файл
@@ -31,7 +31,7 @@ import { spellingExtension } from "./spelling";
import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups";
import { DOCUMENTATION_WINDOW } from "./docs";
import { prefetchMarkdown } from "./markdown";
import { registerNavigationActions } from "@/editor/navigation";
import { implementationGutter, registerNavigationActions } from "@/editor/navigation";
export function lspExtensions(ctx: EditorDocContext): Extension[] {
return [
@@ -61,6 +61,8 @@ export function activate(): () => void {
installDiagnosticsEvents(),
installAvailabilityEvents(),
registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }),
// After the run icons (order 55), as their own gutter column.
registerEditorContribution({ id: "lsp.lineMarkers", order: 56, extensions: implementationGutter }),
// After the templates (order 90): live template / postfix expansion keeps ⇥ first.
registerEditorContribution({ id: "lsp.parameterTab", order: 95, extensions: () => parameterTabKeys }),
registerOverlay({ id: "editor-lsp.popups", component: LspPopups }),
+7 -6
Переглянути файл
@@ -7,7 +7,7 @@ import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { forEachDiagnostic } from "@codemirror/lint";
import { commands, isCancelled, type CodeAction } from "@/platform/ipc";
import { whenSynced } from "@/editor/api";
import { contributedIntentions, whenSynced } from "@/editor/api";
import { shortcutLabel } from "@/platform/keymap";
import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
import { offsetsToRange } from "./positions";
@@ -207,8 +207,8 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
/** ⌥↵ */
export async function showIntentions(view: EditorView) {
try {
// Typo fixes come first and need no language server.
const local = spellingIntentions(view);
// Typo fixes and feature-contributed actions come first and need no language server.
const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)];
let actions: CodeAction[] = [];
let failure: { error: unknown } | null = null;
try {
@@ -338,20 +338,21 @@ const bulbPlugin = ViewPlugin.fromClass(
if (!doc || doc.readOnly || !this.view.dom.isConnected) return;
const state = this.view.state;
const typoFix = spellingIntentions(this.view).length > 0;
const local = typoFix || contributedIntentions(this.view).length > 0;
const backoff = serverBackoff.active(doc.doc);
if (backoff && !typoFix) return;
if (backoff && !local) return;
let actions: CodeAction[] = [];
if (!backoff) {
try {
actions = await fetchIntentions(this.view);
} catch (e) {
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
if (!typoFix) return;
if (!local) return;
}
}
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return;
const line = state.doc.lineAt(state.selection.main.head).number;
const any = actions.length > 0 || typoFix;
const any = actions.length > 0 || local;
this.view.dispatch({
effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null),
});
+1 -1
Переглянути файл
@@ -121,7 +121,7 @@ export function chooserTitle(kind: GotoKind, name: string | null, count: number,
}
/** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */
function pinToFind(symbol: string, locations: readonly Location[]) {
export function pinToFind(symbol: string, locations: readonly Location[]) {
return {
label: "Open in Find Tool Window",
run: () => void executeAction("find.showUsageResults", { symbol, locations }),
+19
Переглянути файл
@@ -0,0 +1,19 @@
/* Editor gutter inheritance icons (implemented / overridden / implementing / overriding). */
.gutter :global(.cm-gutterElement) {
display: flex;
align-items: center;
justify-content: center;
gap: 1px;
}
.marker {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-small);
cursor: pointer;
}
.marker:hover {
background: var(--bg-hover);
}
+392
Переглянути файл
@@ -0,0 +1,392 @@
/**
* Gutter icons for inheritance: "is implemented / overridden" (↓) on types and
* methods with implementations or overriders, "implements / overrides" (↑) on
* methods with a super method. Clicking one navigates to its single target or
* opens the targets chooser.
*
* Generic over language servers: ↓ comes from implementation requests (one
* cancellable batch per pass), ↑ from the type hierarchy and the supertypes'
* document symbols (as Super Method, ⌘U). Only declarations in and near the
* viewport are asked, in one debounced pass after edits / scrolling; results
* are cached per document text, and markers follow their lines in between.
*/
import { RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { docOf, debounce, isUnavailable, lspRequest } from "@/editor/lsp/lsp";
import { serverBackoff } from "@/editor/lsp/availability";
import { readManyFileLines, withDocument } from "@/editor/lsp/files";
import { locationItems, navigateTo, pinToFind } from "@/editor/lsp/navigation";
import { openPopup } from "@/editor/lsp/popupStore";
import {
commands,
isCancelled,
nextRequestId,
onIdeEvent,
type HierarchyItem,
type Location,
type OutlineItem,
type Pos,
type RequestId,
} from "@/platform/ipc";
import { editorPrefs, type EditorExtras } from "@/platform/editorSettings";
import { useProject } from "@/platform/project";
import { iconMarkup } from "@/ui";
import {
MARKER_ICONS,
candidateKey,
inheritorsMarker,
markerCandidates,
supersMarker,
withoutSelf,
type LineMarker,
type MarkerCandidate,
type MarkerGroup,
} from "./lineMarkers";
import { superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
import styles from "./LineMarkers.module.css";
/** Lines above / below the viewport that are covered too, so scrolling feels instant. */
const MARGIN_LINES = 60;
const MAX_PER_PASS = 60;
const DEBOUNCE_MS = 500;
/** Settings › Editor › General › Gutter Icons groups that are on. */
export function enabledGroups(prefs: Pick<EditorExtras, "showGutterIcons" | "gutterIconsDisabled">): Set<MarkerGroup> {
if (!prefs.showGutterIcons) return new Set();
return new Set((["inheritors", "supers"] as const).filter((g) => !prefs.gutterIconsDisabled.includes(g)));
}
export interface MarkerDeps {
/** Implementations of the symbols at `positions`, one list each (rejects when the server cannot answer). */
implementations: (positions: Pos[]) => Promise<Location[][]>;
supers: SuperDeps;
readFiles: (paths: readonly string[]) => Promise<Map<string, string[] | null>>;
}
interface CacheEntry {
inheritors?: LineMarker | null;
supers?: LineMarker | null;
}
/** Results for one document text; `unsupported` remembers a server that cannot answer a group. */
export interface MarkerCache {
entries: Map<string, CacheEntry>;
unsupported: Set<MarkerGroup>;
}
export const emptyCache = (): MarkerCache => ({ entries: new Map(), unsupported: new Set() });
/**
* Asks the server about the `candidates` not in `cache` yet (only the enabled
* `groups`) and stores the answers. Cancellation propagates; an unavailable
* server marks its group unsupported for this cache.
*/
export async function fillMarkers(
path: string,
candidates: readonly MarkerCandidate[],
groups: ReadonlySet<MarkerGroup>,
deps: MarkerDeps,
cache: MarkerCache,
): Promise<void> {
const entry = (c: MarkerCandidate) => {
const key = candidateKey(c);
let e = cache.entries.get(key);
if (!e) cache.entries.set(key, (e = {}));
return e;
};
const down =
groups.has("inheritors") && !cache.unsupported.has("inheritors")
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
: [];
const up =
groups.has("supers") && !cache.unsupported.has("supers")
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
: [];
let implementations: Location[][] = [];
if (down.length) {
try {
implementations = (await deps.implementations(down.map((c) => ({ line: c.line, col: c.col })))).map((locs, i) =>
withoutSelf(path, down[i]!, locs),
);
} catch (e) {
if (isCancelled(e)) throw e;
if (isUnavailable(e)) cache.unsupported.add("inheritors");
down.length = 0;
}
}
const supers: SuperMethod[][] = [];
for (const c of up) supers.push(await superMethodTargets(path, c.owner!, c.symbol, deps.supers));
const files = await deps.readFiles([
...implementations.flat().map((l) => l.path),
...supers.flat().map((s) => s.location.path),
]);
down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[i] ?? [], files)));
up.forEach((c, i) => (entry(c).supers = supersMarker(c, supers[i]!, files)));
}
/** Markers known for `candidates`, in document order (↓ before ↑ on a line). */
export function cachedMarkers(
candidates: readonly MarkerCandidate[],
groups: ReadonlySet<MarkerGroup>,
cache: MarkerCache,
): LineMarker[] {
const out: LineMarker[] = [];
for (const c of candidates) {
const e = cache.entries.get(candidateKey(c));
if (!e) continue;
if (groups.has("inheritors") && e.inheritors) out.push(e.inheritors);
if (groups.has("supers") && e.supers) out.push(e.supers);
}
return out;
}
/** Opens the single target, or the chooser over several. */
export async function openMarkerTargets(marker: LineMarker, anchor: { x: number; y: number }) {
if (marker.targets.length === 1) {
await navigateTo(marker.targets[0]!);
return;
}
openPopup({
kind: "list",
anchor,
title: marker.chooserTitle,
items: await locationItems(marker.targets),
pin: pinToFind(marker.name, marker.targets),
});
}
// ---------------------------------------------------------------------------
// CodeMirror
interface PlacedMarker {
marker: LineMarker;
/** Line start offset in the current state. */
pos: number;
}
const setMarkers = StateEffect.define<PlacedMarker[]>();
const markersField = StateField.define<PlacedMarker[]>({
create: () => [],
update(value, tr) {
for (const e of tr.effects) if (e.is(setMarkers)) return e.value;
if (!tr.docChanged || !value.length) return value;
return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from }));
},
});
function place(doc: Text, markers: readonly LineMarker[]): PlacedMarker[] {
return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from }));
}
const sameMarkers = (a: readonly PlacedMarker[], b: readonly PlacedMarker[]) =>
a.length === b.length &&
a.every(
(m, i) => m.pos === b[i]!.pos && m.marker.kind === b[i]!.marker.kind && m.marker.tooltip === b[i]!.marker.tooltip,
);
class InheritanceMarker extends GutterMarker {
constructor(readonly marker: LineMarker) {
super();
}
override eq(other: GutterMarker): boolean {
return (
other instanceof InheritanceMarker &&
other.marker.kind === this.marker.kind &&
other.marker.tooltip === this.marker.tooltip
);
}
override toDOM(): Node {
const el = document.createElement("div");
el.className = `${styles.marker} cm-openidea-lineMarker-${this.marker.kind}`;
el.dataset.kind = this.marker.kind;
el.title = this.marker.tooltip;
el.setAttribute("role", "button");
el.setAttribute("aria-label", this.marker.tooltip);
el.innerHTML = iconMarkup(MARKER_ICONS[this.marker.kind], 14) ?? "";
return el;
}
}
const gutterMarkers = new WeakMap<readonly PlacedMarker[], RangeSet<GutterMarker>>();
function markerSet(placed: readonly PlacedMarker[]): RangeSet<GutterMarker> {
let set = gutterMarkers.get(placed);
if (!set) {
set = RangeSet.of(
placed.map((m) => new InheritanceMarker(m.marker).range(m.pos)),
true,
);
gutterMarkers.set(placed, set);
}
return set;
}
class MarkersPlugin {
private refresh = debounce(() => void this.run(), DEBOUNCE_MS);
private cache = emptyCache();
private cacheDoc: Text | null = null;
private outline: { doc: Text; items: OutlineItem[]; candidates: MarkerCandidate[] } | null = null;
private generation = 0;
private request: RequestId | null = null;
private offSettings: () => void;
private offServers: () => void;
constructor(private view: EditorView) {
this.refresh();
this.offSettings = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings) this.refresh();
});
// Answers given while a server was starting or indexing may be incomplete.
this.offServers = onIdeEvent("languageServer", (e) => {
if (e.status !== "ready") return;
this.invalidate();
this.refresh();
});
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged) this.refresh();
// Other files may have changed meanwhile (a new implementation).
else if (u.focusChanged && u.view.hasFocus) {
this.invalidate();
this.refresh();
}
}
private invalidate() {
this.cacheDoc = null;
this.outline = null;
}
private cancelRequest() {
if (this.request === null) return;
void commands.cancelRequest(this.request).catch(() => {});
this.request = null;
}
private show(placed: PlacedMarker[]) {
if (!sameMarkers(this.view.state.field(markersField), placed))
this.view.dispatch({ effects: setMarkers.of(placed) });
}
private async run() {
const view = this.view;
if (!view.dom.isConnected) return;
const ctx = docOf(view);
if (!ctx) return;
const gen = ++this.generation;
this.cancelRequest();
const groups = enabledGroups(editorPrefs());
if (!groups.size || serverBackoff.active(ctx.doc)) {
if (!groups.size) this.show([]);
return;
}
const text = view.state.doc;
const stale = () => gen !== this.generation || view.state.doc !== text;
if (this.cacheDoc !== text) {
this.cache = emptyCache();
this.cacheDoc = text;
}
try {
if (this.outline?.doc !== text) {
const items = await lspRequest(() => commands.docOutline(ctx.doc));
if (stale()) return;
this.outline = {
doc: text,
items,
candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : "")),
};
}
const { candidates, items } = this.outline;
const { from, to } = view.viewport;
const first = text.lineAt(from).number - 1 - MARGIN_LINES;
const last = text.lineAt(to).number - 1 + MARGIN_LINES;
const visible = candidates.filter((c) => c.line >= first && c.line <= last).slice(0, MAX_PER_PASS);
const request = (this.request = nextRequestId());
await fillMarkers(ctx.path, visible, groups, markerDeps(ctx, items, request, this.cache), this.cache);
if (this.request === request) this.request = null;
if (stale()) return;
this.show(place(text, cachedMarkers(candidates, groups, this.cache)));
} catch (e) {
if (isUnavailable(e)) serverBackoff.mark(ctx.doc);
else if (!isCancelled(e)) console.debug("[lineMarkers]", e);
}
}
destroy() {
this.refresh.cancel();
this.cancelRequest();
this.offSettings();
this.offServers();
this.generation++;
}
}
/** LSP access for one pass: this document's outline as known, others' opened on demand, each asked once. */
function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: RequestId, cache: MarkerCache): MarkerDeps {
const supertypes = new Map<string, Promise<HierarchyItem[]>>();
const outlines = new Map<string, Promise<OutlineItem[]>>([[ctx.path, Promise.resolve(items)]]);
const hierarchy = (doc: number, pos: Pos) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"));
return {
implementations: (positions) => lspRequest(() => commands.lspImplementations(request, ctx.doc, positions)),
supers: {
supertypes: (path, pos) => {
const key = `${path}:${pos.line}:${pos.col}`;
let p = supertypes.get(key);
if (!p) {
p =
path === ctx.path
? hierarchy(ctx.doc, pos).catch((e) => {
// No type hierarchy for this file: no ↑ markers until the server changes.
if (isUnavailable(e)) cache.unsupported.add("supers");
throw e;
})
: withDocument(path, (doc) => hierarchy(doc, pos));
supertypes.set(key, p);
}
return p;
},
outline: (path) => {
let p = outlines.get(path);
if (!p) outlines.set(path, (p = withDocument(path, (doc) => lspRequest(() => commands.docOutline(doc)))));
return p;
},
},
readFiles: readManyFileLines,
};
}
function markerAt(view: EditorView, lineFrom: number, el: Element | null): LineMarker | null {
const onLine = view.state.field(markersField).filter((m) => m.pos === lineFrom);
const kind = (el?.closest(`.${styles.marker}`) as HTMLElement | null)?.dataset.kind;
return (onLine.find((m) => m.marker.kind === kind) ?? (onLine.length === 1 ? onLine[0] : null))?.marker ?? null;
}
export function implementationGutter(ctx: EditorDocContext): Extension {
if (ctx.doc < 0) return [];
return [
markersField,
ViewPlugin.fromClass(MarkersPlugin),
gutter({
class: `${styles.gutter} cm-openidea-gutter-lineMarkers`,
markers: (view) => markerSet(view.state.field(markersField)),
domEventHandlers: {
mousedown: (view, block, event) => {
const e = event as MouseEvent;
if (e.button !== 0) return false;
const target = e.target instanceof Element ? e.target : null;
const marker = markerAt(view, block.from, target);
if (!marker) return false;
e.preventDefault();
const rect = (target?.closest(`.${styles.marker}`) ?? target)?.getBoundingClientRect();
void openMarkerTargets(marker, rect ? { x: rect.left, y: rect.bottom + 2 } : { x: e.clientX, y: e.clientY });
return true;
},
},
}),
];
}
+2
Переглянути файл
@@ -173,3 +173,5 @@ export function registerNavigationActions(): () => void {
editorAction("navigate.previousMethod", "Previous Method", (v) => gotoMethod(v, -1)),
]);
}
export { implementationGutter } from "./implementationGutter";
+353
Переглянути файл
@@ -0,0 +1,353 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
IpcError,
type HierarchyItem,
type Location,
type OutlineItem,
type Pos,
type SymbolKind,
} from "@/platform/ipc";
const nav = vi.hoisted(() => ({ navigateTo: vi.fn(async () => {}), openPopup: vi.fn() }));
vi.mock("@/editor/lsp/navigation", async (orig) => ({
...(await orig<typeof import("@/editor/lsp/navigation")>()),
navigateTo: nav.navigateTo,
locationItems: async (locs: Location[]) => locs.map((l, i) => ({ id: `${i}`, label: l.path, run: () => {} })),
}));
vi.mock("@/editor/lsp/popupStore", async (orig) => ({
...(await orig<typeof import("@/editor/lsp/popupStore")>()),
openPopup: nav.openPopup,
}));
import { candidateKey, markerCandidates, type MarkerCandidate } from "./lineMarkers";
import {
cachedMarkers,
emptyCache,
enabledGroups,
fillMarkers,
openMarkerTargets,
type MarkerDeps,
} from "./implementationGutter";
const ALL = enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] });
function item(
name: string,
kind: SymbolKind,
line: number,
end: number,
children: OutlineItem[] = [],
col = 4,
): OutlineItem {
return {
name,
kind,
detail: null,
range: { start: { line, col: 0 }, end: { line: end, col: 1 } },
selection_range: { start: { line, col }, end: { line, col: col + name.length } },
children,
};
}
const loc = (path: string, line: number, col = 4): Location => ({
path,
range: { start: { line, col }, end: { line, col: col + 3 } },
});
const hier = (name: string, kind: SymbolKind, path: string, line = 0): HierarchyItem => ({
name,
kind,
detail: null,
path,
range: { start: { line, col: 0 }, end: { line: line + 20, col: 1 } },
selectionRange: { start: { line, col: 4 }, end: { line, col: 4 + name.length } },
fromRanges: [],
});
// Shape.java, as jdtls outlines it.
const SHAPE = [
"public interface Shape {",
" double area();",
' default String label() { return ""; }',
" static Shape unit() { return null; }",
"}",
];
const SHAPE_OUTLINE = [
item(
"Shape",
"interface",
0,
4,
[
item("area()", "method", 1, 1, [], 11),
item("label()", "method", 2, 2, [], 19),
item("unit()", "method", 3, 3, [], 17),
],
17,
),
];
const SHAPE_PATH = "/p/Shape.java";
// Circle.java: implements Shape, overrides Base.label().
const CIRCLE = [
"public class Circle extends Base implements Shape {",
" @Override",
" public double area() { return 1; }",
" private void helper() {}",
' public final String label() { return "c"; }',
"}",
];
const CIRCLE_OUTLINE = [
item(
"Circle",
"class",
0,
5,
[item("area()", "method", 1, 2), item("helper()", "method", 3, 3, [], 17), item("label()", "method", 4, 4, [], 24)],
13,
),
];
// Make `area()`'s identifier sit on line 2 (its range starts at the annotation).
CIRCLE_OUTLINE[0]!.children[0]!.selection_range = { start: { line: 2, col: 18 }, end: { line: 2, col: 22 } };
const BASE = [
"public abstract class Base {",
" public abstract void run();",
' public String label() { return ""; }',
"}",
];
const BASE_OUTLINE = [
item("Base", "class", 0, 3, [item("run()", "method", 1, 1, [], 25), item("label()", "method", 2, 2, [], 18)], 22),
];
const FILES: Record<string, string[]> = {
[SHAPE_PATH]: SHAPE,
"/p/Circle.java": CIRCLE,
"/p/Square.java": ["public class Square implements Shape {", " public double area() { return 2; }", "}"],
"/p/Base.java": BASE,
};
const lines = (text: string[]) => (l: number) => text[l] ?? "";
describe("marker candidates", () => {
it("covers inheritable types and their methods, without private / static / final ones", () => {
const shape = markerCandidates(SHAPE_OUTLINE, lines(SHAPE));
expect(shape.map((c) => [c.name, c.abstract, c.inheritable, c.overriding])).toEqual([
["Shape", true, true, false],
["area", true, true, true],
// A default method has a body: overriding it is not implementing it.
["label", false, true, true],
["unit", true, false, false],
]);
const circle = markerCandidates(CIRCLE_OUTLINE, lines(CIRCLE));
expect(circle.map((c) => [c.name, c.line, c.inheritable, c.overriding])).toEqual([
["Circle", 0, true, false],
["area", 2, true, true],
["helper", 3, false, false],
["label", 4, false, true],
]);
expect(markerCandidates(BASE_OUTLINE, lines(BASE)).map((c) => [c.name, c.abstract])).toEqual([
["Base", true],
["run", true],
["label", false],
]);
});
it("skips structs, enums and free functions for implementation requests", () => {
const outline = [item("Point", "struct", 0, 3, [item("len", "method", 1, 1)]), item("main", "function", 5, 6)];
const found = markerCandidates(outline, () => "");
expect(found.map((c) => [c.name, c.inheritable, c.overriding])).toEqual([["len", false, true]]);
});
});
/** A server answering implementation and type hierarchy requests over the files above. */
function server(impls: Record<string, Location[]>, supers: Record<string, HierarchyItem[]>) {
const calls = { implementations: [] as Pos[][], supertypes: 0 };
const outlines: Record<string, OutlineItem[]> = {
[SHAPE_PATH]: SHAPE_OUTLINE,
"/p/Circle.java": CIRCLE_OUTLINE,
"/p/Base.java": BASE_OUTLINE,
};
const deps = (path: string): MarkerDeps => ({
implementations: async (positions) => {
calls.implementations.push(positions);
return positions.map((p) => impls[`${path}:${p.line}`] ?? []);
},
supers: {
supertypes: async (p) => {
calls.supertypes++;
return supers[p] ?? [];
},
outline: async (p) => outlines[p] ?? [],
},
readFiles: async (paths) => new Map(paths.map((p) => [p, FILES[p] ?? null])),
});
return { calls, deps };
}
describe("markers from server answers", () => {
const shapeImpls = {
// The server lists the declaration itself too.
[`${SHAPE_PATH}:0`]: [loc(SHAPE_PATH, 0), loc("/p/Circle.java", 0, 13), loc("/p/Square.java", 0, 13)],
[`${SHAPE_PATH}:1`]: [loc("/p/Circle.java", 2, 18), loc("/p/Square.java", 1, 18)],
};
it("marks implemented interfaces and methods with every implementation as targets", async () => {
const { deps, calls } = server(shapeImpls, {});
const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE));
const cache = emptyCache();
await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache);
// One batch for every inheritable declaration.
expect(calls.implementations).toEqual([
[
{ line: 0, col: 17 },
{ line: 1, col: 11 },
{ line: 2, col: 19 },
],
]);
const markers = cachedMarkers(candidates, ALL, cache);
expect(markers.map((m) => [m.line, m.kind, m.targets.length, m.tooltip])).toEqual([
[0, "implemented", 2, "Is implemented by Circle, Square"],
[1, "implemented", 2, "Is implemented in Circle, Square"],
]);
expect(markers[0]!.chooserTitle).toBe("Choose Implementation of Shape (2 found)");
});
it("marks subclassed classes and overridden methods", async () => {
const { deps } = server(
{
"/p/Base.java:0": [loc("/p/Circle.java", 0, 13)],
"/p/Base.java:2": [loc("/p/Circle.java", 4, 25)],
},
{},
);
const candidates = markerCandidates(BASE_OUTLINE, lines(BASE));
const cache = emptyCache();
await fillMarkers("/p/Base.java", candidates, ALL, deps("/p/Base.java"), cache);
const markers = cachedMarkers(candidates, ALL, cache);
// `abstract class Base` is implemented, `label()` overridden.
expect(markers.map((m) => [m.line, m.kind, m.tooltip])).toEqual([
[0, "implemented", "Is implemented by Circle"],
[2, "overridden", "Is overridden in Circle"],
]);
});
it("marks implementing and overriding methods from the type hierarchy", async () => {
const supers = {
"/p/Circle.java": [hier("Base", "class", "/p/Base.java"), hier("Shape", "interface", SHAPE_PATH)],
};
const { deps, calls } = server({}, supers);
const candidates = markerCandidates(CIRCLE_OUTLINE, lines(CIRCLE));
const cache = emptyCache();
await fillMarkers("/p/Circle.java", candidates, ALL, deps("/p/Circle.java"), cache);
const markers = cachedMarkers(candidates, ALL, cache);
expect(
markers.map((m) => [m.line, m.kind, m.tooltip, m.targets.map((t) => `${t.path}:${t.range.start.line}`)]),
).toEqual([
[2, "implementing", "Implements method in Shape", [`${SHAPE_PATH}:1`]],
// Base.label() and the default Shape.label() have bodies.
[4, "overriding", "Overrides method in Base, Shape", ["/p/Base.java:2", `${SHAPE_PATH}:2`]],
]);
expect(markers[1]!.chooserTitle).toBe("Choose Super Method of label (2 found)");
expect(calls.supertypes).toBeGreaterThan(0);
});
it("asks once per document version and only for enabled groups", async () => {
const { deps, calls } = server(shapeImpls, {});
const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE));
const cache = emptyCache();
await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache);
await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache);
expect(calls.implementations).toHaveLength(1);
// A new document version starts from an empty cache.
await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), emptyCache());
expect(calls.implementations).toHaveLength(2);
const supersOnly = enabledGroups({ showGutterIcons: true, gutterIconsDisabled: ["inheritors"] });
const fresh = emptyCache();
await fillMarkers(SHAPE_PATH, candidates, supersOnly, deps(SHAPE_PATH), fresh);
expect(calls.implementations).toHaveLength(2);
expect(cachedMarkers(candidates, ALL, cache)).toHaveLength(2);
expect(cachedMarkers(candidates, supersOnly, cache)).toHaveLength(0);
});
it("stops asking a server that cannot answer, and caches nothing when cancelled", async () => {
const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE));
const failing = (kind: "unavailable" | "cancelled") => {
const calls = { n: 0 };
const deps: MarkerDeps = {
implementations: async () => {
calls.n++;
throw new IpcError({ kind, message: kind });
},
supers: { supertypes: async () => [], outline: async () => [] },
readFiles: async () => new Map(),
};
return { calls, deps };
};
const off = failing("unavailable");
const cache = emptyCache();
await fillMarkers(SHAPE_PATH, candidates, ALL, off.deps, cache);
await fillMarkers(SHAPE_PATH, candidates, ALL, off.deps, cache);
expect(off.calls.n).toBe(1);
expect(cache.unsupported.has("inheritors")).toBe(true);
const cancelled = failing("cancelled");
const kept = emptyCache();
await expect(fillMarkers(SHAPE_PATH, candidates, ALL, cancelled.deps, kept)).rejects.toThrow();
expect(kept.entries.size).toBe(0);
});
});
describe("settings", () => {
it("follows Settings › Gutter Icons", () => {
expect([...enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] })]).toEqual(["inheritors", "supers"]);
expect([...enabledGroups({ showGutterIcons: true, gutterIconsDisabled: ["supers", "run"] })]).toEqual([
"inheritors",
]);
expect(enabledGroups({ showGutterIcons: false, gutterIconsDisabled: [] }).size).toBe(0);
});
});
describe("clicking a marker", () => {
beforeEach(() => {
nav.navigateTo.mockClear();
nav.openPopup.mockClear();
});
const candidate = (): MarkerCandidate => markerCandidates(SHAPE_OUTLINE, lines(SHAPE))[0]!;
it("navigates to a single target", async () => {
const target = loc("/p/Circle.java", 0, 13);
await openMarkerTargets(
{ kind: "implemented", line: 0, name: "Shape", targets: [target], tooltip: "", chooserTitle: "" },
{ x: 1, y: 2 },
);
expect(nav.navigateTo).toHaveBeenCalledWith(target);
expect(nav.openPopup).not.toHaveBeenCalled();
});
it("opens the chooser over several targets", async () => {
const targets = [loc("/p/Circle.java", 0, 13), loc("/p/Square.java", 0, 13)];
expect(candidateKey(candidate())).toBe("0:17:Shape");
await openMarkerTargets(
{
kind: "implemented",
line: 0,
name: "Shape",
targets,
tooltip: "",
chooserTitle: "Choose Implementation of Shape (2 found)",
},
{ x: 1, y: 2 },
);
expect(nav.navigateTo).not.toHaveBeenCalled();
expect(nav.openPopup).toHaveBeenCalledWith(
expect.objectContaining({
kind: "list",
anchor: { x: 1, y: 2 },
title: "Choose Implementation of Shape (2 found)",
}),
);
expect(nav.openPopup.mock.calls[0]![0].items).toHaveLength(2);
});
});
+222
Переглянути файл
@@ -0,0 +1,222 @@
/**
* Inheritance line markers: which declarations of a document get an
* "is implemented / overridden" (↓) or "implements / overrides" (↑) gutter
* icon, from the language server's answers (implementation requests and the
* type hierarchy). Pure; the gutter itself is `implementationGutter`.
*/
import type { Location, OutlineItem, SymbolKind } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { identifierAt } from "@/editor/lsp/files";
import { baseName, type SuperMethod } from "./structureNav";
export type MarkerKind = "implemented" | "overridden" | "implementing" | "overriding";
/** Settings › Gutter Icons groups: ↓ markers and ↑ markers. */
export type MarkerGroup = "inheritors" | "supers";
export const markerGroup = (kind: MarkerKind): MarkerGroup =>
kind === "implemented" || kind === "overridden" ? "inheritors" : "supers";
export const MARKER_ICONS: Record<MarkerKind, string> = {
implemented: "gutter-implemented",
overridden: "gutter-overridden",
implementing: "gutter-implementing",
overriding: "gutter-overriding",
};
export interface MarkerCandidate {
/** Outline name (`eat(String)` with jdtls), matched against supertypes' members. */
symbol: string;
/** Bare name for titles. */
name: string;
/** 0-based line / UTF-16 col of the identifier. */
line: number;
col: number;
method: boolean;
/** Declared abstract: an interface / trait, a member of one, or marked `abstract`. */
abstract: boolean;
/** Can have implementations / overriders (asked with an implementation request). */
inheritable: boolean;
/** Can implement / override a supertype's method (looked up in the type hierarchy). */
overriding: boolean;
/** Identifier position of the enclosing type (methods). */
owner: { line: number; col: number } | null;
}
export interface LineMarker {
kind: MarkerKind;
line: number;
/** Name of the declaration the marker is on. */
name: string;
targets: Location[];
tooltip: string;
/** Title of the targets chooser. */
chooserTitle: string;
}
/** Types whose subtypes / implementations are worth a marker (not structs, enums: they cannot be extended). */
const INHERITABLE_TYPES = new Set<SymbolKind>(["class", "interface", "trait"]);
const ABSTRACT_TYPES = new Set<SymbolKind>(["interface", "trait"]);
/** Types whose methods may implement or override a supertype's. */
const MEMBER_OWNERS = new Set<SymbolKind>(["class", "interface", "trait", "enum", "struct"]);
/** Modifiers that rule out implementations / overriders. */
const NOT_INHERITABLE = /\b(private|static|final)\b/;
const NOT_OVERRIDING = /\b(private|static)\b/;
const ABSTRACT = /\babstract\b/;
/** An interface method with a body (Java `default`). */
const DEFAULT = /\bdefault\b/;
/** The declaration's text before its identifier (annotations and modifiers included). */
function declarationPrefix(item: OutlineItem, lineText: (line: number) => string): string {
const { start } = item.range;
const at = item.selection_range.start;
if (start.line > at.line) return lineText(at.line).slice(0, at.col);
const lines: string[] = [];
for (let l = start.line; l <= at.line && lines.length < 12; l++) {
const text = lineText(l);
lines.push(
l === at.line
? text.slice(l === start.line ? start.col : 0, at.col)
: l === start.line
? text.slice(start.col)
: text,
);
}
// Annotation arguments (`@Foo(final = …)`) are not modifiers.
return lines.join(" ").replace(/@[\w.]+(\([^)]*\))?/g, " ");
}
/** Declarations that may get a marker, in document order. */
export function markerCandidates(
outline: readonly OutlineItem[],
lineText: (line: number) => string,
): MarkerCandidate[] {
const out: MarkerCandidate[] = [];
const walk = (items: readonly OutlineItem[], parent: OutlineItem | null) => {
for (const it of items) {
const prefix = () => declarationPrefix(it, lineText);
const at = it.selection_range.start;
if (INHERITABLE_TYPES.has(it.kind)) {
const p = prefix();
out.push({
symbol: it.name,
name: baseName(it.name),
line: at.line,
col: at.col,
method: false,
abstract: ABSTRACT_TYPES.has(it.kind) || ABSTRACT.test(p),
inheritable: !NOT_INHERITABLE.test(p),
overriding: false,
owner: null,
});
} else if (it.kind === "method" && parent && MEMBER_OWNERS.has(parent.kind)) {
const p = prefix();
const inParent = ABSTRACT_TYPES.has(parent.kind);
out.push({
symbol: it.name,
name: baseName(it.name),
line: at.line,
col: at.col,
method: true,
abstract: (inParent && !DEFAULT.test(p)) || ABSTRACT.test(p),
inheritable: INHERITABLE_TYPES.has(parent.kind) && !NOT_INHERITABLE.test(p),
overriding: !NOT_OVERRIDING.test(p),
owner: parent.selection_range.start,
});
}
if (it.children.length) walk(it.children, it);
}
};
walk(outline, null);
return out.sort((a, b) => a.line - b.line || a.col - b.col);
}
export const candidateKey = (c: MarkerCandidate) => `${c.line}:${c.col}:${c.symbol}`;
/** Servers answer an implementation request on a declaration with the declaration itself too. */
export function withoutSelf(path: string, c: MarkerCandidate, targets: readonly Location[]): Location[] {
return targets.filter((t) => !(t.path === path && t.range.start.line === c.line));
}
const TYPE_DECLARATION = /\b(?:class|interface|trait|object|struct|enum|record|impl)\s+([\p{L}_$][\p{L}\p{N}_$]*)/u;
/** Name of the type a target sits in: the nearest type declaration above it, else the file name. */
export function enclosingTypeName(lines: readonly string[] | null | undefined, target: Location): string {
for (let l = target.range.start.line - 1; lines && l >= 0 && l >= target.range.start.line - 2000; l--) {
const m = TYPE_DECLARATION.exec(lines[l] ?? "");
if (m && !/^\s*(\/\/|\*|\/\*)/.test(lines[l] ?? "")) return m[1]!;
}
const file = target.path.split(/[\\/]/).pop() ?? target.path;
return file.replace(/\.[^.]+$/, "");
}
/** Display name of a target: the type itself for type markers, the type around it for methods. */
export function targetName(lines: readonly string[] | null | undefined, target: Location, method: boolean): string {
if (!method) {
const name = identifierAt(lines?.[target.range.start.line] ?? "", target.range.start.col);
if (name) return name;
}
return enclosingTypeName(lines, target);
}
const MAX_NAMES = 8;
function nameList(names: readonly string[]): string {
const unique = [...new Set(names)].sort((a, b) => a.localeCompare(b));
const shown = unique.slice(0, MAX_NAMES).join(", ");
return unique.length > MAX_NAMES ? tx`${shown} and ${unique.length - MAX_NAMES} more` : shown;
}
/** ↓ marker of `c` from its implementations (self excluded already); null without any. */
export function inheritorsMarker(
c: MarkerCandidate,
targets: readonly Location[],
files: ReadonlyMap<string, string[] | null>,
): LineMarker | null {
if (!targets.length) return null;
const kind: MarkerKind = c.abstract ? "implemented" : "overridden";
const names = nameList(targets.map((t) => targetName(files.get(t.path), t, c.method)));
const n = targets.length;
let tooltip: string;
let chooserTitle: string;
if (!c.method) {
tooltip = c.abstract ? tx`Is implemented by ${names}` : tx`Is subclassed by ${names}`;
chooserTitle = c.abstract
? tx`Choose Implementation of ${c.name} (${n} found)`
: tx`Choose Subclass of ${c.name} (${n} found)`;
} else {
tooltip = c.abstract ? tx`Is implemented in ${names}` : tx`Is overridden in ${names}`;
chooserTitle = c.abstract
? tx`Choose Implementation of ${c.name} (${n} found)`
: tx`Choose Overriding Method of ${c.name} (${n} found)`;
}
return { kind, line: c.line, name: c.name, targets: [...targets], tooltip, chooserTitle };
}
/** Whether a super method is abstract: declared in an interface / trait, or marked `abstract`. */
export function isAbstractSuper(s: SuperMethod, files: ReadonlyMap<string, string[] | null>): boolean {
const lines = files.get(s.location.path);
const prefix = lines ? declarationPrefix(s.method, (l) => lines[l] ?? "") : "";
return (ABSTRACT_TYPES.has(s.owner.kind) && !DEFAULT.test(prefix)) || ABSTRACT.test(prefix);
}
/** ↑ marker of method `c` from its super methods; null without any. */
export function supersMarker(
c: MarkerCandidate,
supers: readonly SuperMethod[],
files: ReadonlyMap<string, string[] | null>,
): LineMarker | null {
if (!supers.length) return null;
// Implementing wins when one of several super methods is abstract, as an interface method would.
const implementing = supers.some((s) => isAbstractSuper(s, files));
const owners = nameList(supers.map((s) => baseName(s.owner.name)));
return {
kind: implementing ? "implementing" : "overriding",
line: c.line,
name: c.name,
targets: supers.map((s) => s.location),
tooltip: implementing ? tx`Implements method in ${owners}` : tx`Overrides method in ${owners}`,
chooserTitle: tx`Choose Super Method of ${c.name} (${supers.length} found)`,
};
}
+21
Переглянути файл
@@ -63,4 +63,25 @@ describe("Super Method", () => {
// jdtls names methods with their parameters.
expect(await superMethods("/Foo.java", { line: 0, col: 4 }, "a() : void", deps)).toHaveLength(1);
});
it("prefers the overload with the same signature", async () => {
const base: HierarchyItem = {
name: "Base",
kind: "class",
detail: null,
path: "/Base.java",
range: r(0, 10),
selectionRange: { start: { line: 0, col: 4 }, end: { line: 0, col: 8 } },
fromRanges: [],
};
const deps = {
supertypes: async (path: string) => (path === "/Foo.java" ? [base] : []),
outline: async () => [item("Base", "class", 0, 10, [item("eat(String)", "method", 2, 2), item("eat(int)", "method", 4, 4)])],
};
const lines = async (name: string) => (await superMethods("/Foo.java", { line: 0, col: 4 }, name, deps)).map((l) => l.range.start.line);
expect(await lines("eat(int)")).toEqual([4]);
// Without a matching signature (or without signatures at all), every overload.
expect(await lines("eat(T)")).toEqual([2, 4]);
expect(await lines("eat")).toEqual([2, 4]);
});
});
+35 -8
Переглянути файл
@@ -60,8 +60,11 @@ export function memberAt(outline: readonly OutlineItem[], caret: Pos): CaretMemb
/** `beta(int)` (jdtls) and `beta` name the same method. */
export const baseName = (name: string) => name.replace(/\s*[(<].*$/, "");
/** Methods named `name` directly inside the type declared at `item` (its outline node found by position). */
export function methodsInType(outline: readonly OutlineItem[], item: HierarchyItem, name: string): OutlineItem[] {
/** `beta(int) : void` (jdtls) is the signature `beta(int)`; plain names stay as they are. */
const signature = (name: string) => name.replace(/\s*:.*$/, "");
/** The outline node of the type declared at `item` (found by position). */
export function typeOf(outline: readonly OutlineItem[], item: HierarchyItem): OutlineItem | null {
const at = item.selectionRange.start;
const find = (items: readonly OutlineItem[]): OutlineItem | null => {
for (const i of items) {
@@ -71,9 +74,20 @@ export function methodsInType(outline: readonly OutlineItem[], item: HierarchyIt
}
return null;
};
const type = find(outline);
return find(outline);
}
/**
* Methods named `name` directly inside the type declared at `item`: the
* overload with the same signature when the server names them with one
* (`eat(String)`), else every method of that name.
*/
export function methodsInType(outline: readonly OutlineItem[], item: HierarchyItem, name: string): OutlineItem[] {
const type = typeOf(outline, item);
const bare = baseName(name);
return type ? type.children.filter((c) => METHODS.has(c.kind) && baseName(c.name) === bare) : [];
const named = type ? type.children.filter((c) => METHODS.has(c.kind) && baseName(c.name) === bare) : [];
const exact = named.filter((c) => signature(c.name) === signature(name));
return exact.length ? exact : named;
}
export interface SuperDeps {
@@ -81,13 +95,20 @@ export interface SuperDeps {
outline: (path: string) => Promise<OutlineItem[]>;
}
/** Super methods of `method` (a member of the type at `typePos`), searching up to `depth` levels of supertypes. */
export async function superMethods(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise<Location[]> {
export interface SuperMethod {
location: Location;
/** The supertype declaring it. */
owner: HierarchyItem;
method: OutlineItem;
}
/** Super methods of `method` (a member of the type at `typePos`), with their declaring types, nearest level first. */
export async function superMethodTargets(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise<SuperMethod[]> {
let level: Array<{ path: string; pos: Pos }> = [{ path, pos: typePos }];
const seen = new Set<string>();
for (let d = 0; d < depth && level.length; d++) {
const next: Array<{ path: string; pos: Pos }> = [];
const found: Location[] = [];
const found: SuperMethod[] = [];
for (const t of level) {
const supers = await deps.supertypes(t.path, t.pos).catch(() => [] as HierarchyItem[]);
for (const s of supers) {
@@ -95,7 +116,8 @@ export async function superMethods(path: string, typePos: Pos, method: string, d
if (seen.has(key)) continue;
seen.add(key);
const methods = methodsInType(await deps.outline(s.path).catch(() => [] as OutlineItem[]), s, method);
if (methods.length) for (const m of methods) found.push({ path: s.path, range: m.selection_range });
if (methods.length)
for (const m of methods) found.push({ location: { path: s.path, range: m.selection_range }, owner: s, method: m });
else next.push({ path: s.path, pos: s.selectionRange.start });
}
}
@@ -104,3 +126,8 @@ export async function superMethods(path: string, typePos: Pos, method: string, d
}
return [];
}
/** Super methods of `method` (a member of the type at `typePos`), searching up to `depth` levels of supertypes. */
export async function superMethods(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise<Location[]> {
return (await superMethodTargets(path, typePos, method, deps, depth)).map((s) => s.location);
}
+3 -2
Переглянути файл
@@ -9,7 +9,7 @@ import { Composer } from "./Composer";
import { canSend, emptyDraft } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
import { providerIcon } from "./model";
import { HOME, startTask, updateDraft, useAgents } from "./store";
import { HOME, NO_COMMANDS, startTask, updateDraft, useAgentCommands, useAgents } from "./store";
import styles from "./Home.module.css";
export function HomeEditor() {
@@ -36,6 +36,7 @@ export function HomeEditor() {
}, []);
const provider = providers.find((p) => p.id === draft.provider) ?? providers[0];
const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS);
// Installed agents first; the rest under a "Not installed" title, with the command they need.
const installed = providers.filter((p) => p.installed);
const missing = providers.filter((p) => !p.installed);
@@ -70,7 +71,7 @@ export function HomeEditor() {
placeholder="Describe a task for the agent. @ references a file, / runs a command"
root={root}
projectRoot={root}
commands={provider?.commands ?? []}
commands={agentCommands}
history={history}
config={provider?.config ?? []}
onConfig={setOption}
+3 -1
Переглянути файл
@@ -25,6 +25,7 @@ import {
sendMessage,
sendQueuedNow,
setTaskConfig,
useAgentCommands,
useAgents,
} from "./store";
import styles from "./Session.module.css";
@@ -279,6 +280,7 @@ function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEnt
const project = useProject((s) => s.project);
const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft();
const [sending, setSending] = useState(false);
const agentCommands = useAgentCommands(task.provider, task.id, task.commands);
const busy = isBusy(task.status);
// ↑ recalls this conversation's messages, newest first.
const history = useMemo(
@@ -304,7 +306,7 @@ function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEnt
placeholder={busy ? "Queue a message for when the agent is done (⌘↵ sends it now)" : "Reply to the agent"}
root={task.cwd}
projectRoot={project?.root ?? task.cwd}
commands={task.commands}
commands={agentCommands}
history={history}
config={task.config}
onConfig={setOption}
+15
Переглянути файл
@@ -66,6 +66,21 @@ describe("agent composer", () => {
expect(matchCommands(cmds, "rev").map((c) => c.name)).toEqual(["review", "pr-review"]);
});
it("matches commands by prefix, plugin name, substring, then letters in order", () => {
const cmds = ["triage", "verify", "issue-fix", "brooks-lint:brooks-review", "pr-review", "init"].map((name) => ({
name,
description: "",
hint: null,
}));
const names = (q: string) => matchCommands(cmds, q).map((c) => c.name);
expect(names("trig")).toEqual(["triage"]);
expect(names("brooks-r")).toEqual(["brooks-lint:brooks-review"]);
expect(names("rev")).toEqual(["brooks-lint:brooks-review", "pr-review"]);
expect(names("if")).toEqual(["verify", "issue-fix"]);
expect(names("xyz")).toEqual([]);
expect(names("")).toHaveLength(cmds.length);
});
it("sends only the files still mentioned", () => {
const prompt = toPrompt({
text: " look at @src/a.rs:3-5 and @b.rs ",
+88
Переглянути файл
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { AgentCommand, AgentProvider } from "@/bindings";
const agentCommands = vi.fn();
const agentCreate = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: {
...real.commands,
agentCommands: (provider: string, task: string | null) => agentCommands(provider, task),
agentCreate: (request: unknown) => agentCreate(request),
vcsBranches: () => Promise.resolve({ status: "ok", data: [] }),
},
};
});
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
}));
import { HomeEditor } from "./HomeEditor";
import { HOME, updateComposer, useAgents } from "./store";
import { emptyDraft } from "./composerModel";
const command = (name: string, description: string): AgentCommand => ({ name, description, hint: null });
const claude: AgentProvider = {
id: "claude",
name: "Claude Code",
command: "npx",
installed: true,
config: [],
// Offered by the agent's last session in this project.
commands: [command("compact", "Free up context")],
};
beforeEach(() => {
agentCommands.mockReset();
agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } });
useAgents.setState({
providers: [claude],
draft: { provider: "claude", config: {}, isolation: "local", base: null },
});
updateComposer(HOME, emptyDraft());
});
describe("New Task composer commands", () => {
it("completes the project's skills merged with the agent's commands", async () => {
agentCommands.mockResolvedValue({
status: "ok",
data: [
command("compact", "Free up context"),
command("triage", "One pass over open issues and PRs"),
command("issue-fix", "Fix an issue"),
],
});
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalledWith("claude", null));
const box = screen.getByRole("textbox");
fireEvent.change(box, { target: { value: "/trig" } });
const list = await screen.findByRole("listbox", { name: "Commands" });
const options = within(list).getAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual(["/triageOne pass over open issues and PRs"]);
fireEvent.keyDown(box, { key: "Enter" });
expect(useAgents.getState().composers.get(HOME)?.text).toBe("/triage ");
// Sent as typed, so the agent runs the skill.
fireEvent.change(box, { target: { value: "/triage dry-run" } });
fireEvent.keyDown(box, { key: "Enter" });
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ prompt: { text: "/triage dry-run" }, provider: "claude" });
});
it("offers nothing the project does not have", async () => {
agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] });
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
const box = screen.getByRole("textbox");
fireEvent.change(box, { target: { value: "/trig" } });
expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull();
fireEvent.change(box, { target: { value: "/com" } });
expect(await screen.findByRole("option")).toHaveTextContent("/compact");
});
});
+21 -3
Переглянути файл
@@ -40,11 +40,29 @@ export function applyCompletion(text: string, c: Completion, insert: string): {
return { text: next, caret: c.from + insert.length + 1 };
}
/** Commands matching `query`, prefix matches first. */
/**
* Commands matching `query`: prefix matches first (a plugin's "plugin:name" also by its
* name), then substrings, then names holding its letters in order ("trig" → "triage").
*/
export function matchCommands(commands: readonly AgentCommand[], query: string): AgentCommand[] {
const q = query.toLowerCase();
const hits = commands.filter((c) => c.name.toLowerCase().includes(q));
return hits.sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q)));
const rank = (name: string) => {
const n = name.toLowerCase();
if (n.startsWith(q) || n.slice(n.lastIndexOf(":") + 1).startsWith(q)) return 0;
if (n.includes(q)) return 1;
return inOrder(n, q) ? 2 : -1;
};
return commands
.map((command) => ({ command, rank: rank(command.name) }))
.filter((m) => m.rank >= 0)
.sort((a, b) => a.rank - b.rank)
.map((m) => m.command);
}
function inOrder(text: string, query: string): boolean {
let i = 0;
for (const ch of text) if (ch === query[i]) i++;
return i >= query.length;
}
/** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */
+38 -1
Переглянути файл
@@ -1,7 +1,16 @@
/** Agent tasks of the open project, mirrored from `ide-agent` through `agentSubscribe`. */
import { Channel } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { create } from "zustand";
import type { AgentChange, AgentEntry, AgentEvent, AgentProvider, AgentTask, Isolation } from "@/bindings";
import type {
AgentChange,
AgentCommand,
AgentEntry,
AgentEvent,
AgentProvider,
AgentTask,
Isolation,
} from "@/bindings";
import { call, commands, isTauri } from "@/platform/ipc";
import { editorService, notifications } from "@/workbench/contributions";
import { canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
@@ -212,6 +221,34 @@ async function refreshProviders() {
if (providers) useAgents.setState({ providers });
}
export const NO_COMMANDS: readonly AgentCommand[] = [];
/**
* Slash commands to complete: what the agent offered (`offered`; fetched again when it
* changes) plus, for Claude Code, the skills and custom commands on disk. Without `task`,
* those of the project.
*/
export function useAgentCommands(
provider: string | undefined,
task: string | null,
offered: readonly AgentCommand[],
): readonly AgentCommand[] {
const [found, setFound] = useState<{ for: unknown[]; list: readonly AgentCommand[] } | null>(null);
useEffect(() => {
if (!provider || !isTauri()) return;
let alive = true;
call(commands.agentCommands(provider, task))
.then((list) => alive && setFound({ for: [provider, task, offered], list }))
.catch(() => {});
return () => {
alive = false;
};
}, [provider, task, offered]);
// Until the list for these arguments arrives, what the agent offered.
const current = found && found.for[0] === provider && found.for[1] === task && found.for[2] === offered;
return current ? found.list : offered;
}
export function openTask(task: string) {
// The title rides along so a restored tab has it before the tasks load.
const title = useAgents.getState().tasks.get(task)?.title;
+31 -1
Переглянути файл
@@ -1,7 +1,8 @@
/**
* HTTP Client (IDEA's `.http` / `.rest` files): highlighting, ▶ gutter per
* request, environments, Run All, and a tool window with the responses and
* their history. Parsing, variable resolution and sending happen in Rust
* their history, and requests generated from Spring Web mappings in Java /
* Kotlin sources (`spring/`). Parsing, variable resolution and sending happen in Rust
* (`ide-http`).
*/
import { activeEditor, registerEditorContribution, registerLanguageMode } from "@/editor/api";
@@ -19,6 +20,15 @@ import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } f
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
import { httpLanguageDescription } from "./language";
import { SERVICE_VIEW, resetHttpClient, runAll, runRequest } from "./store";
import {
GENERATE_CONTROLLER_REQUESTS,
GENERATE_REQUEST,
generateAtCaret,
menuGroupItems,
springEditorExtension,
} from "./spring/editor";
const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin";
export const NEW_REQUEST_TEMPLATE = `### Example request
# Variables come from http-client.env.json / http-client.private.env.json
@@ -105,6 +115,8 @@ export function activate(): () => void {
}),
registerLanguageMode(httpLanguageDescription),
registerEditorContribution({ id: "http.requests", order: 56, extensions: httpEditorExtension }),
registerEditorContribution({ id: "http.spring", order: 57, extensions: springEditorExtension }),
registerMenuGroup({ id: "http.spring", menus: ["editor"], order: 40, items: (_menu, ctx) => menuGroupItems(ctx) }),
registerServiceView({
id: SERVICE_VIEW,
title: "HTTP Client",
@@ -156,6 +168,24 @@ export function activate(): () => void {
else if (isTauri()) await newRequest(directory ?? undefined);
},
},
{
id: GENERATE_REQUEST,
title: "Generate Request in HTTP Client",
category: "Tools",
icon: "file-http",
keywords: ["spring", "request mapping", "rest", "endpoint", "http client"],
when: SPRING_WHEN,
run: () => generateAtCaret("endpoint"),
},
{
id: GENERATE_CONTROLLER_REQUESTS,
title: "Generate Requests for Controller",
category: "Tools",
icon: "file-http",
keywords: ["spring", "request mapping", "rest", "endpoints", "http client"],
when: SPRING_WHEN,
run: () => generateAtCaret("controller"),
},
{
id: "http.showToolWindow",
title: "HTTP Client",
+346
Переглянути файл
@@ -0,0 +1,346 @@
/**
* Spring Web in Java / Kotlin editors: a gutter icon on every request mapping
* and controller, context actions and editor menu items that generate the
* requests into the `generated-requests.http` scratch and open it.
*/
import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import {
activeEditor,
editorsFor,
intentionProviders,
useEditorRegistry,
type ContributedIntention,
type EditorDocContext,
type TextEditorHandle,
} from "@/editor/api";
import { tx } from "@/platform/i18n";
import { call, commands, emitLocalIdeEvent, IpcError } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { iconMarkup, showContextMenu, type MenuItem } from "@/ui";
import { editorService, notifications, type MenuGroupContext } from "@/workbench/contributions";
import { springAt, springControllers, type Controller, type Endpoint } from "./endpoints";
import { appendRequests, bodyLookup, findHost, renderRequest, skeleton, type SourceReader } from "./request";
import { parseSource } from "./source";
import styles from "../HttpClient.module.css";
export const SPRING_LANGUAGES = new Set(["java", "kotlin"]);
export const GENERATED_REQUESTS = "generated-requests.http";
export const GENERATE_REQUEST = "http.generateSpringRequest";
export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests";
const reader: SourceReader = {
read: async (path) => {
try {
return await call(commands.fsReadText(path));
} catch {
return null;
}
},
};
/** One request per endpoint (`paths[i]` picks another path of the mapping). */
export interface Target {
endpoint: Endpoint;
path?: string;
}
async function scratchFile(): Promise<{ path: string; created: boolean }> {
const { appDataDir } = await import("@tauri-apps/api/path");
const dir = `${(await appDataDir()).replace(/[\\/]+$/, "")}/scratches`;
const ignoreExisting = (e: unknown) => {
if (!(e instanceof IpcError && e.kind === "alreadyExists")) throw e;
};
await call(commands.fsCreateDir(dir)).catch(ignoreExisting);
const path = `${dir}/${GENERATED_REQUESTS}`;
let created = true;
await call(commands.fsCreateFile(path)).catch((e) => {
ignoreExisting(e);
created = false;
});
return { path, created };
}
/** The text editor of `path`, once the just opened tab has created it. */
function editorFor(path: string, timeoutMs = 5000): Promise<TextEditorHandle | null> {
const find = () => editorsFor(path).find((x) => x === activeEditor()) ?? editorsFor(path)[0] ?? null;
const now = find();
if (now) return Promise.resolve(now);
return new Promise((resolve) => {
const done = (e: TextEditorHandle | null) => {
clearTimeout(timer);
unsubscribe();
resolve(e);
};
const timer = setTimeout(() => done(null), timeoutMs);
const unsubscribe = useEditorRegistry.subscribe(() => {
const e = find();
if (e) done(e);
});
});
}
/** Renders `targets` of the source at `sourcePath`, appends them to the scratch and opens it at the first one. */
export async function generateRequests(sourcePath: string, text: string, targets: Target[]): Promise<void> {
if (!targets.length) return;
try {
const root = useProject.getState().project?.root ?? null;
const [host, lookup] = await Promise.all([
findHost(sourcePath, root, reader),
bodyLookup(
sourcePath,
parseSource(text),
targets.map((t) => t.endpoint),
reader,
),
]);
const blocks = targets.map(({ endpoint, path }) =>
renderRequest(endpoint, {
host,
path,
body: endpoint.bodyType ? skeleton(endpoint.bodyType, lookup) : undefined,
}),
);
const file = await scratchFile();
if (file.created) emitLocalIdeEvent({ type: "fsChanged", created: [file.path], modified: [], removed: [] });
await editorService().openFile(file.path, { focus: true });
const e = await editorFor(file.path);
if (!e) throw new Error(`${tx("Cannot open file")}: ${file.path}`);
const { insert, from, caret } = appendRequests(e.view.state.doc.toString(), blocks);
e.view.dispatch({
changes: { from, insert },
selection: { anchor: caret },
effects: EditorView.scrollIntoView(caret, { y: "center" }),
userEvent: "input",
});
} catch (err) {
notifications().notify({
level: "error",
title: "Cannot generate the HTTP request",
body: err instanceof Error ? err.message : String(err),
key: "http-generate",
});
}
}
function endpointItems(sourcePath: string, text: string, e: Endpoint): MenuItem[] {
const generate = (path?: string) => void generateRequests(sourcePath, text, [{ endpoint: e, path }]);
return [
e.paths.length > 1
? {
label: "Generate Request in HTTP Client",
icon: "file-http",
submenu: e.paths.map((p) => ({ label: `${e.httpMethod} ${p}`, verbatim: true, run: () => generate(p) })),
}
: { label: "Generate Request in HTTP Client", icon: "file-http", run: () => generate() },
];
}
function controllerItems(sourcePath: string, text: string, c: Controller): MenuItem[] {
return c.endpoints.length
? [
{
label: "Generate Requests for Controller",
icon: "file-http",
run: () =>
void generateRequests(
sourcePath,
text,
c.endpoints.map((endpoint) => ({ endpoint })),
),
},
]
: [];
}
/** Editor context menu items for the caret at `offset`: the generate actions (a path choice for several paths). */
export function springMenuItems(sourcePath: string, text: string, offset: number): MenuItem[] {
const { controller, endpoint } = springAt(springControllers(text), offset);
const items: MenuItem[] = [];
if (endpoint)
items.push(
endpoint.paths.length > 1
? endpointItems(sourcePath, text, endpoint)[0]!
: { kind: "action", action: GENERATE_REQUEST, icon: "file-http" },
);
if (controller?.endpoints.length)
items.push({ kind: "action", action: GENERATE_CONTROLLER_REQUESTS, icon: "file-http" });
return items;
}
export function menuGroupItems(ctx: MenuGroupContext): MenuItem[] {
const path = ctx.paths[0];
if (!path || ctx.text === undefined || ctx.line === undefined || !/\.(java|kt)$/.test(path)) return [];
// The actions work at the caret: only the active editor's menu offers them.
const e = activeEditor();
if (!e || e.ctx.path !== path || !SPRING_LANGUAGES.has(e.ctx.language)) return [];
return springMenuItems(path, ctx.text, e.view.state.selection.main.head);
}
/** The generate actions at the caret of the active editor. */
export async function generateAtCaret(scope: "endpoint" | "controller"): Promise<void> {
const e = activeEditor();
if (!e || !SPRING_LANGUAGES.has(e.ctx.language)) return;
const text = e.view.state.doc.toString();
const { controller, endpoint } = springAt(springControllers(text), e.view.state.selection.main.head);
const endpoints = scope === "endpoint" ? (endpoint ? [endpoint] : []) : (controller?.endpoints ?? []);
if (!endpoints.length) {
notifications().notify({
level: "info",
title: scope === "endpoint" ? "No Spring request mapping at the caret" : "No Spring controller at the caret",
key: "http-generate",
});
return;
}
await generateRequests(
e.ctx.path,
text,
endpoints.map((x) => ({ endpoint: x })),
);
}
// --- gutter ----------------------------------------------------------------------
interface Mark {
pos: number;
/** Endpoint key or the controller's name. */
key: string;
title: string;
items: (path: string, text: string) => MenuItem[];
}
const setMarks = StateEffect.define<Mark[]>();
const marksField = StateField.define<Mark[]>({
create: () => [],
update(value, tr) {
for (const e of tr.effects) if (e.is(setMarks)) return e.value;
if (!tr.docChanged || !value.length) return value;
return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from }));
},
});
function computeMarks(state: EditorState): Mark[] {
const doc = state.doc;
const marks: Mark[] = [];
const lineStart = (offset: number) => doc.lineAt(Math.min(offset, doc.length)).from;
for (const c of springControllers(doc.toString())) {
if (c.endpoints.length)
marks.push({
pos: lineStart(c.offset),
key: `class:${c.name}`,
title: "Generate Requests for Controller",
items: (path, text) => controllerItems(path, text, c),
});
for (const e of c.endpoints)
marks.push({
pos: lineStart(e.offset),
key: `${e.httpMethod} ${e.paths.join(",")}`,
title: `${e.httpMethod} ${e.paths[0] ?? "/"}`,
items: (path, text) => endpointItems(path, text, e),
});
}
// One icon per line (the first wins).
return marks.filter((m, i) => marks.findIndex((x) => x.pos === m.pos) === i);
}
class SpringMarker extends GutterMarker {
constructor(readonly mark: Mark) {
super();
}
override eq(other: GutterMarker): boolean {
return other instanceof SpringMarker && other.mark.key === this.mark.key && other.mark.title === this.mark.title;
}
override toDOM(): Node {
const el = document.createElement("div");
el.className = styles.runMarker ?? "";
el.title = this.mark.title;
el.setAttribute("role", "button");
el.setAttribute("aria-label", this.mark.title);
el.innerHTML = iconMarkup("file-http", 14) ?? "";
return el;
}
}
const DEBOUNCE = 400;
export function springEditorExtension(ctx: EditorDocContext): Extension {
if (!SPRING_LANGUAGES.has(ctx.language) || ctx.virtual || isVirtualUri(ctx.path)) return [];
const path = ctx.path;
const detector = ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | undefined;
constructor(readonly view: EditorView) {
this.schedule(0);
}
update(u: ViewUpdate) {
if (u.docChanged) this.schedule(DEBOUNCE);
}
schedule(delay: number) {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
const found = computeMarks(this.view.state);
const cur = this.view.state.field(marksField);
const same =
cur.length === found.length && cur.every((m, i) => m.pos === found[i]!.pos && m.key === found[i]!.key);
if (!same) this.view.dispatch({ effects: setMarks.of(found) });
}, delay);
}
destroy() {
clearTimeout(this.timer);
}
},
);
const intentions = (view: EditorView): ContributedIntention[] => {
// Only files that have mappings (the gutter found some) are parsed again here.
if (!view.state.field(marksField, false)?.length) return [];
const text = view.state.doc.toString();
const { controller, endpoint } = springAt(springControllers(text), view.state.selection.main.head);
const out: ContributedIntention[] = [];
if (endpoint)
out.push({
id: GENERATE_REQUEST,
label: "Generate Request in HTTP Client",
icon: "file-http",
run: () => generateRequests(path, text, [{ endpoint }]),
});
if (controller?.endpoints.length)
out.push({
id: GENERATE_CONTROLLER_REQUESTS,
label: "Generate Requests for Controller",
icon: "file-http",
run: () =>
generateRequests(
path,
text,
controller.endpoints.map((e) => ({ endpoint: e })),
),
});
return out;
};
return [
marksField,
detector,
intentionProviders.of(intentions),
gutter({
class: `${styles.runGutter} cm-openidea-gutter-spring`,
markers: (view) =>
RangeSet.of(
view.state.field(marksField).map((m) => new SpringMarker(m).range(m.pos)),
true,
),
domEventHandlers: {
mousedown: (view, block, event) => {
const mark = view.state.field(marksField).find((m) => m.pos === block.from);
if (!mark || (event as MouseEvent).button !== 0) return false;
const el = (event.target as HTMLElement).closest?.(`.${styles.runMarker}`) as HTMLElement | null;
const rect = (el ?? (event.target as HTMLElement)).getBoundingClientRect();
event.preventDefault();
showContextMenu({ x: rect.left, y: rect.bottom + 2 }, mark.items(path, view.state.doc.toString()));
return true;
},
},
}),
];
}
+297
Переглянути файл
@@ -0,0 +1,297 @@
/**
* Spring Web request mappings of a Java / Kotlin source: `@RequestMapping` /
* `@GetMapping`… methods of `@RestController` / `@Controller` classes, with
* the class-level mapping applied. Constants are resolved within the file.
*/
import {
allClasses,
parseSource,
type Annotation,
type ClassDecl,
type Expr,
type Param,
type SourceFile,
} from "./source";
export interface QueryParam {
name: string;
/** `defaultValue`, if any. */
value: string | null;
}
export interface Endpoint {
className: string;
methodName: string;
/** Offset of the method name (gutter line). */
offset: number;
/** The method declaration, annotations included. */
range: { from: number; to: number };
/** Offset of the controller class name. */
classOffset: number;
httpMethod: string;
/** Class × method paths (`/` joined, `{var}` kept), first one first. */
paths: string[];
query: QueryParam[];
headers: { name: string; value: string }[];
/** `consumes` (first). */
consumes: string | null;
/** `produces` (first). */
produces: string | null;
/** `@RequestBody` parameter type as written, if any. */
bodyType: string | null;
}
export interface Controller {
name: string;
offset: number;
/** The class declaration, annotations included. */
range: { from: number; to: number };
endpoints: Endpoint[];
}
const SHORTCUTS: Record<string, string> = {
GetMapping: "GET",
PostMapping: "POST",
PutMapping: "PUT",
DeleteMapping: "DELETE",
PatchMapping: "PATCH",
};
const CONTROLLER = new Set(["RestController", "Controller"]);
const MEDIA_TYPES: Record<string, string> = {
ALL: "*/*",
APPLICATION_JSON: "application/json",
APPLICATION_JSON_UTF8: "application/json;charset=UTF-8",
APPLICATION_XML: "application/xml",
APPLICATION_ATOM_XML: "application/atom+xml",
APPLICATION_FORM_URLENCODED: "application/x-www-form-urlencoded",
APPLICATION_OCTET_STREAM: "application/octet-stream",
APPLICATION_PDF: "application/pdf",
APPLICATION_PROBLEM_JSON: "application/problem+json",
APPLICATION_PROBLEM_XML: "application/problem+xml",
APPLICATION_NDJSON: "application/x-ndjson",
APPLICATION_STREAM_JSON: "application/stream+json",
APPLICATION_CBOR: "application/cbor",
APPLICATION_GRAPHQL_RESPONSE: "application/graphql-response+json",
IMAGE_GIF: "image/gif",
IMAGE_JPEG: "image/jpeg",
IMAGE_PNG: "image/png",
MULTIPART_FORM_DATA: "multipart/form-data",
MULTIPART_MIXED: "multipart/mixed",
TEXT_EVENT_STREAM: "text/event-stream",
TEXT_HTML: "text/html",
TEXT_MARKDOWN: "text/markdown",
TEXT_PLAIN: "text/plain",
TEXT_XML: "text/xml",
};
/** Evaluates string-valued annotation arguments within one file. */
export class Constants {
private readonly fields = new Map<string, Expr>();
constructor(file: SourceFile) {
for (const f of [...file.fields, ...allClasses(file).flatMap((c) => c.fields)])
if (f.init && !this.fields.has(f.name)) this.fields.set(f.name, f.init);
}
/** Every element of an array (`{a, b}`, `[a, b]`, `arrayOf(a, b)`) or the single value; unresolvable ones dropped. */
strings(expr: Expr | undefined, enums = false): string[] {
if (!expr?.length) return [];
const first = expr[0]!;
let inner: Expr | null = null;
if (first.kind === "p" && (first.text === "{" || first.text === "[")) inner = expr.slice(1, -1);
else if (first.kind === "id" && /^(arrayOf|listOf|setOf)$/.test(first.text) && expr[1]?.text === "(")
inner = expr.slice(2, -1);
if (!inner) {
const v = this.value(expr, enums, new Set());
return v === null ? [] : [v];
}
return splitTop(inner)
.map((e) => this.value(e, enums, new Set()))
.filter((v): v is string => v !== null);
}
string(expr: Expr | undefined, enums = false): string | null {
return this.strings(expr, enums)[0] ?? null;
}
private value(expr: Expr, enums: boolean, seen: Set<string>): string | null {
let out = "";
for (const part of splitPlus(expr)) {
const v = this.part(part, enums, seen);
if (v === null) return null;
out += v;
}
return out;
}
private part(part: Expr, enums: boolean, seen: Set<string>): string | null {
// Kotlin templates of constants; other `${…}` are property placeholders, kept.
if (part.length === 1 && part[0]!.kind === "str")
return (part[0]!.value ?? "").replace(
/\$\{(\w+)\}|\$([A-Za-z_]\w*)/g,
(m, a: string | undefined, b: string | undefined) => {
const name = (a ?? b)!;
const init = seen.has(name) ? undefined : this.fields.get(name);
return init ? (this.value(init, false, new Set([...seen, name])) ?? m) : m;
},
);
if (part.length === 1 && part[0]!.kind === "num") return part[0]!.text;
if (part[0]?.text === "(" && part[part.length - 1]?.text === ")") return this.value(part.slice(1, -1), enums, seen);
// `NAME`, `Paths.NAME`, `MediaType.APPLICATION_JSON_VALUE`, `RequestMethod.GET`.
if (!part.every((t, i) => (i % 2 ? t.kind === "p" && t.text === "." : t.kind === "id"))) return null;
const names = part.filter((t) => t.kind === "id").map((t) => t.text);
const last = names[names.length - 1]!;
const qualifier = names[names.length - 2];
if (qualifier === "MediaType") return MEDIA_TYPES[last.replace(/_VALUE$/, "")] ?? null;
const init = this.fields.get(last);
if (init && !seen.has(last)) {
seen.add(last);
return this.value(init, enums, seen);
}
return enums ? last : null;
}
}
function splitTop(expr: Expr): Expr[] {
const parts: Expr[] = [];
let cur: Expr = [];
let depth = 0;
for (const t of expr) {
if (t.kind === "p" && "([{".includes(t.text)) depth++;
if (t.kind === "p" && ")]}".includes(t.text)) depth--;
if (depth === 0 && t.kind === "p" && t.text === ",") {
parts.push(cur);
cur = [];
} else cur.push(t);
}
if (cur.length) parts.push(cur);
return parts;
}
function splitPlus(expr: Expr): Expr[] {
const parts: Expr[] = [];
let cur: Expr = [];
let depth = 0;
for (const t of expr) {
if (t.kind === "p" && "([{".includes(t.text)) depth++;
if (t.kind === "p" && ")]}".includes(t.text)) depth--;
if (depth === 0 && t.kind === "p" && t.text === "+") {
parts.push(cur);
cur = [];
} else cur.push(t);
}
parts.push(cur);
return parts;
}
function mapping(annotations: Annotation[]): { ann: Annotation; method: string | null } | null {
for (const ann of annotations) {
if (SHORTCUTS[ann.name]) return { ann, method: SHORTCUTS[ann.name]! };
if (ann.name === "RequestMapping") return { ann, method: null };
}
return null;
}
function mappingPaths(ann: Annotation | undefined, c: Constants): string[] {
if (!ann) return [];
return c.strings(ann.args.get("path") ?? ann.args.get("value"));
}
export function joinPaths(base: string, path: string): string {
const joined = `/${base}/${path}`.replace(/\/{2,}/g, "/");
return joined.length > 1 ? joined.replace(/\/$/, "") : "/";
}
function annotation(p: Param, name: string): Annotation | undefined {
return p.annotations.find((a) => a.name === name);
}
/** Map-typed `@RequestParam` / `@RequestHeader` collect all of them: nothing to generate. */
const isMap = (type: string) =>
/^(java\.util\.)?(Map|MultiValueMap|HttpHeaders|LinkedMultiValueMap|HashMap)\b/.test(type);
function endpoint(cls: ClassDecl, method: ClassDecl["methods"][number], c: Constants): Endpoint | null {
const m = mapping(method.annotations);
if (!m) return null;
const classMapping = cls.annotations.find((a) => a.name === "RequestMapping");
const httpMethod =
m.method ??
c.string(m.ann.args.get("method"), true) ??
(classMapping && c.string(classMapping.args.get("method"), true)) ??
"GET";
const bases = mappingPaths(classMapping, c);
const own = mappingPaths(m.ann, c);
const paths: string[] = [];
for (const b of bases.length ? bases : [""]) for (const p of own.length ? own : [""]) paths.push(joinPaths(b, p));
const query: QueryParam[] = [];
const headers: { name: string; value: string }[] = [];
let bodyType: string | null = null;
for (const p of method.params) {
const rp = annotation(p, "RequestParam");
const rh = annotation(p, "RequestHeader");
if (rp && !isMap(p.type)) {
const name = c.string(rp.args.get("value")) || c.string(rp.args.get("name")) || p.name;
query.push({ name, value: c.string(rp.args.get("defaultValue")) });
} else if (rh && !isMap(p.type)) {
const name = c.string(rh.args.get("value")) || c.string(rh.args.get("name")) || p.name;
headers.push({ name, value: c.string(rh.args.get("defaultValue")) ?? `{{${name}}}` });
} else if (annotation(p, "RequestBody")) bodyType = p.type || "Object";
}
// `params = "a=1"` / `headers = "X-Api=1"` narrow the mapping: the request must carry them.
for (const cond of [...c.strings(classMapping?.args.get("params")), ...c.strings(m.ann.args.get("params"))]) {
const [name, value] = cond.split("=", 2);
if (name && !cond.includes("!") && !query.some((q) => q.name === name.trim()))
query.push({ name: name.trim(), value: value?.trim() ?? null });
}
for (const cond of [...c.strings(classMapping?.args.get("headers")), ...c.strings(m.ann.args.get("headers"))]) {
const [name, value] = cond.split("=", 2);
if (name && !cond.includes("!") && value !== undefined) headers.push({ name: name.trim(), value: value.trim() });
}
const first = (key: string) =>
c.string(m.ann.args.get(key)) ?? (classMapping && c.string(classMapping.args.get(key))) ?? null;
return {
className: cls.name,
methodName: method.name,
offset: method.nameStart,
range: { from: method.start, to: method.end },
classOffset: cls.nameStart,
httpMethod: httpMethod.toUpperCase(),
paths,
query,
headers,
consumes: first("consumes"),
produces: first("produces"),
bodyType,
};
}
export function controllersOf(file: SourceFile): Controller[] {
const constants = new Constants(file);
const out: Controller[] = [];
for (const cls of allClasses(file)) {
if (!cls.annotations.some((a) => CONTROLLER.has(a.name))) continue;
const endpoints = cls.methods.map((m) => endpoint(cls, m, constants)).filter((e): e is Endpoint => !!e);
out.push({ name: cls.name, offset: cls.nameStart, range: { from: cls.start, to: cls.end }, endpoints });
}
return out;
}
/** Controllers of a Java / Kotlin source, with their endpoints. */
export function springControllers(text: string): Controller[] {
// Cheap guard: most files are not controllers.
if (!/@(?:[\w.]+\.)?(?:Rest)?Controller\b/.test(text)) return [];
return controllersOf(parseSource(text));
}
/** The controller and endpoint whose declaration holds `offset`. */
export function springAt(
controllers: Controller[],
offset: number,
): { controller: Controller | null; endpoint: Endpoint | null } {
// Innermost controller (nested classes come after their outer one).
const controller = controllers.filter((c) => c.range.from <= offset && offset <= c.range.to).pop() ?? null;
const endpoint = controller?.endpoints.find((e) => e.range.from <= offset && offset <= e.range.to) ?? null;
return { controller, endpoint };
}
+254
Переглянути файл
@@ -0,0 +1,254 @@
/**
* Spring endpoints → `.http` requests: the server address from
* `application.properties` / `application.yml`, `{var}` → `{{var}}`, query
* parameters, headers and a JSON skeleton of the `@RequestBody` type.
*/
import type { Endpoint } from "./endpoints";
import { allClasses, parseSource, type ClassDecl, type SourceFile } from "./source";
export const DEFAULT_HOST = "http://localhost:8080";
/** Flat `key → value` of a `.properties` file (`=`, `:` or space separated; `#` / `!` comments). */
export function parseProperties(text: string): Map<string, string> {
const out = new Map<string, string>();
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
let line = lines[i]!.trim();
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
while (line.endsWith("\\") && i + 1 < lines.length) line = line.slice(0, -1) + lines[++i]!.trim();
const m = /^((?:\\.|[^=:\s])+)\s*[=:\s]\s*(.*)$/.exec(line);
if (m && !out.has(m[1]!)) out.set(m[1]!.replace(/\\(.)/g, "$1"), m[2]!.trim());
}
return out;
}
/** Flat dotted keys of a YAML file's first document (block mappings and scalars only; enough for `server.*`). */
export function parseYaml(text: string): Map<string, string> {
const out = new Map<string, string>();
const stack: { indent: number; key: string }[] = [];
let started = false;
for (const raw of text.split(/\r?\n/)) {
if (/^---/.test(raw)) {
if (started) break;
continue;
}
const line = raw.replace(/\s+#.*$/, "");
if (!line.trim() || line.trim().startsWith("#") || line.trim().startsWith("- ")) continue;
const m = /^(\s*)("[^"]*"|'[^']*'|[^:]+?)\s*:(?:\s+(.*))?$/.exec(line);
if (!m) continue;
started = true;
const indent = m[1]!.length;
while (stack.length && stack[stack.length - 1]!.indent >= indent) stack.pop();
const key = m[2]!.replace(/^["']|["']$/g, "");
const path = [...stack.map((s) => s.key), key].join(".");
const value = m[3]?.trim();
if (value && value !== "|" && value !== ">") {
if (!out.has(path)) out.set(path, value.replace(/^"(.*)"$|^'(.*)'$/, "$1$2"));
} else stack.push({ indent, key });
}
return out;
}
/** `${PORT:8081}` → `8081`; a placeholder without a default is unknown. */
function resolved(value: string | undefined): string | null {
if (value === undefined) return null;
let unknown = false;
const out = value.replace(/\$\{[^}:]*(?::([^}]*))?\}/g, (_m, def: string | undefined) => {
if (def === undefined) unknown = true;
return def ?? "";
});
return unknown ? null : out;
}
/** `http://localhost:<server.port><context path>` from Spring Boot configuration (first file with a value wins). */
export function hostFromConfig(configs: Map<string, string>[]): string {
const get = (...keys: string[]) => {
for (const c of configs)
for (const k of keys) {
const v = resolved(c.get(k));
if (v) return v;
}
return null;
};
const port = get("server.port");
const ssl = /^true$/i.test(get("server.ssl.enabled") ?? "") || !!get("server.ssl.key-store");
const scheme = ssl ? "https" : "http";
const portNum = port && /^\d+$/.test(port) && port !== "0" ? Number(port) : 8080;
const context = [
get("server.servlet.context-path", "server.context-path", "spring.webflux.base-path") ?? "",
get("spring.mvc.servlet.path") ?? "",
]
.join("/")
.replace(/\/{2,}/g, "/")
.replace(/\/$/, "");
const ctx = context && !context.startsWith("/") ? `/${context}` : context;
return `${scheme}://localhost:${portNum}${ctx}`;
}
const NUMBER =
/^(byte|short|int|long|float|double|Byte|Short|Integer|Long|Float|Double|Int|BigDecimal|BigInteger|Number|UByte|UShort|UInt|ULong)\??$/;
const BOOLEAN = /^(boolean|Boolean)\??$/;
const STRING =
/^(String|CharSequence|char|Character|Char|UUID|LocalDate|LocalDateTime|LocalTime|Instant|OffsetDateTime|ZonedDateTime|Date|Duration|URI|URL)\??$/;
const COLLECTION =
/^(List|ArrayList|LinkedList|Set|HashSet|TreeSet|LinkedHashSet|Collection|Iterable|MutableList|MutableSet|Flux|Array|.*Array)(<.*>)?\??$|\[\]$|\.\.\.$/;
const MAP = /^(Map|HashMap|LinkedHashMap|TreeMap|MutableMap|MultiValueMap|JsonNode|ObjectNode)(<.*>)?\??$/;
/** Unwraps `Mono<T>`, `Optional<T>`, `HttpEntity<T>`, `@Valid`-less qualified names and nullability. */
export function bareType(type: string): string {
let t = type.trim().replace(/\?$/, "");
for (;;) {
const m = /^(?:[\w.]*\.)?(Mono|Optional|HttpEntity|RequestEntity|Deferred|CompletableFuture)<(.*)>$/.exec(t);
if (!m) break;
t = m[2]!.trim();
}
return t.replace(/^(?:[a-z_$][\w$]*\.)+/, "");
}
export type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
/** Placeholder value for a field / body of `type`; classes found by `lookup` get their properties (one level deep for nested ones). */
export function skeleton(type: string, lookup: (name: string) => ClassDecl | null, depth = 0): Json {
const t = bareType(type);
if (NUMBER.test(t)) return 0;
if (BOOLEAN.test(t)) return false;
if (STRING.test(t)) return "";
if (COLLECTION.test(t)) return [];
if (MAP.test(t) || t === "Object" || t === "Any" || !t) return {};
const cls = lookup(t.replace(/<.*$/, ""));
if (!cls || depth > 1) return {};
const obj: { [k: string]: Json } = {};
for (const p of cls.ctorParams) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of cls.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
return obj;
}
const pathVariables = (path: string) => path.replace(/\{\*?([\w.-]+)(?::[^{}]*(?:\{[^{}]*\}[^{}]*)*)?\}/g, "{{$1}}");
export interface RenderOptions {
host: string;
/** Path to use (one of `endpoint.paths`); default the first. */
path?: string;
/** JSON body of the `@RequestBody` type. */
body?: Json;
}
/** One `### Name` request block, ending with a line break. */
export function renderRequest(e: Endpoint, opts: RenderOptions): string {
const path = pathVariables(opts.path ?? e.paths[0] ?? "/");
const query = e.query.map((q) => `${q.name}=${q.value ?? `{{${q.name}}}`}`).join("&");
const lines = [
`### ${e.className}.${e.methodName}`,
`${e.httpMethod} ${opts.host}${path}${query ? `?${query}` : ""}`,
];
const headers = [...e.headers];
const contentType = e.consumes ?? (e.bodyType ? "application/json" : null);
if (e.produces) headers.unshift({ name: "Accept", value: e.produces });
if (contentType && (e.bodyType || e.consumes)) headers.push({ name: "Content-Type", value: contentType });
for (const h of headers) lines.push(`${h.name}: ${h.value}`);
if (e.bodyType) {
lines.push("");
const body = opts.body ?? {};
const form =
contentType === "application/x-www-form-urlencoded" && body && typeof body === "object" && !Array.isArray(body);
lines.push(
form
? Object.keys(body)
.map((k) => `${k}=`)
.join("&")
: JSON.stringify(body, null, 2),
);
}
return `${lines.join("\n")}\n`;
}
/** Text to append to a request file holding `existing`, and the offset (in the new text) of the first request line. */
export function appendRequests(existing: string, blocks: string[]): { insert: string; from: number; caret: number } {
const from = existing.length;
let sep = "";
if (existing.trim()) sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
const insert = sep + blocks.join("\n");
const firstLine = blocks[0]?.indexOf("\n") ?? -1;
return { insert, from, caret: from + sep.length + (firstLine >= 0 ? firstLine + 1 : 0) };
}
// --- project lookups -------------------------------------------------------------
export interface SourceReader {
/** File text, null when missing. */
read(path: string): Promise<string | null>;
}
const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, "");
/** The Spring Boot configuration files for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null): string[] {
const norm = sourcePath.replace(/\\/g, "/");
const at = norm.lastIndexOf("/src/main/");
const roots = at >= 0 ? [norm.slice(0, at)] : [];
if (projectRoot && !roots.includes(projectRoot)) roots.push(projectRoot.replace(/\\/g, "/").replace(/\/$/, ""));
return roots.flatMap((r) =>
["application.properties", "application.yml", "application.yaml"].map((f) => `${r}/src/main/resources/${f}`),
);
}
export async function findHost(sourcePath: string, projectRoot: string | null, fs: SourceReader): Promise<string> {
const configs: Map<string, string>[] = [];
for (const path of configCandidates(sourcePath, projectRoot)) {
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
}
return hostFromConfig(configs);
}
/**
* Body types of `endpoints`: classes of the controller's file, then
* `<Type>.java|.kt` beside it or where its import points (same source root).
*/
export async function bodyLookup(
sourcePath: string,
file: SourceFile,
endpoints: Endpoint[],
fs: SourceReader,
): Promise<(name: string) => ClassDecl | null> {
const known = new Map<string, ClassDecl>();
const add = (f: SourceFile) => {
for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c);
};
add(file);
const sourceRoot = (path: string, f: SourceFile) => {
let root = dirname(path);
for (let i = f.packageName ? f.packageName.split(".").length : 0; i > 0; i--) root = dirname(root);
return root;
};
const ext = /\.kt$/.test(sourcePath) ? [".kt", ".java"] : [".java", ".kt"];
/** Loads `name` as seen from `from` (its imports, then its directory). */
const load = async (name: string, from: { path: string; file: SourceFile }) => {
if (known.has(name) || !/^[A-Z]\w*$/.test(name)) return null;
const imported = from.file.imports.find((i) => i.endsWith(`.${name}`));
const dir = dirname(from.path);
const pkgDir = imported && `${sourceRoot(from.path, from.file)}/${imported.split(".").slice(0, -1).join("/")}`;
for (const d of pkgDir ? [pkgDir, dir] : [dir])
for (const x of ext) {
const path = `${d}/${name}${x}`;
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
const loaded = parseSource(text);
add(loaded);
return { path, file: loaded };
}
return null;
};
const typeName = (t: string) => bareType(t).replace(/<.*$/, "");
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : [])));
for (const n of names) {
const from = (await load(n, origin)) ?? origin;
// Property types one level down, as seen from the body type's file.
const c = known.get(n);
for (const p of c ? [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)] : [])
await load(typeName(p.type), from);
}
return (name) => known.get(name) ?? null;
}
+560
Переглянути файл
@@ -0,0 +1,560 @@
/**
* A declaration-level reader for Java and Kotlin sources: classes, methods,
* fields and their annotations with unevaluated argument expressions. Enough
* to read Spring Web mappings (no types, no statements); comments, strings and
* bracket nesting are handled exactly, the rest is skipped.
*/
export interface Tok {
kind: "id" | "str" | "num" | "p";
text: string;
/** String literals: the contents (escapes decoded). */
value?: string;
start: number;
end: number;
/** A line break precedes the token. */
nl: boolean;
}
/** Unevaluated expression: its tokens. */
export type Expr = Tok[];
export interface Annotation {
/** Simple name (`GetMapping` of `@org.springframework…GetMapping`). */
name: string;
/** Arguments by name; a positional argument is `value`. */
args: Map<string, Expr>;
}
export interface Param {
name: string;
/** Type as written, without spaces (`List<String>`); "" when unknown. */
type: string;
annotations: Annotation[];
}
export interface Field {
name: string;
type: string;
isStatic: boolean;
init: Expr | null;
}
export interface Method {
name: string;
annotations: Annotation[];
params: Param[];
/** Offset of the name. */
nameStart: number;
/** Whole declaration, annotations included. */
start: number;
end: number;
}
export interface ClassDecl {
name: string;
annotations: Annotation[];
nameStart: number;
/** Whole declaration, annotations included. */
start: number;
end: number;
/** Kotlin `object` / companion: its properties are static. */
isObject: boolean;
methods: Method[];
fields: Field[];
/** Java record components / Kotlin primary constructor parameters (`val`/`var` ones are properties). */
ctorParams: Param[];
classes: ClassDecl[];
}
export interface SourceFile {
packageName: string;
imports: string[];
classes: ClassDecl[];
/** Top-level properties (Kotlin). */
fields: Field[];
}
const ID_START = /[A-Za-z_$]/;
const ID_PART = /[\w$]/;
export function tokenize(text: string): Tok[] {
const out: Tok[] = [];
let i = 0;
let nl = false;
const n = text.length;
while (i < n) {
const c = text[i]!;
if (c === "\n") {
nl = true;
i++;
continue;
}
if (c === " " || c === "\t" || c === "\r" || c === "\f") {
i++;
continue;
}
if (c === "/" && text[i + 1] === "/") {
while (i < n && text[i] !== "\n") i++;
continue;
}
if (c === "/" && text[i + 1] === "*") {
const close = text.indexOf("*/", i + 2);
const end = close < 0 ? n : close + 2;
if (text.slice(i, end).includes("\n")) nl = true;
i = end;
continue;
}
const start = i;
if (c === '"' && text.startsWith('"""', i)) {
const close = text.indexOf('"""', i + 3);
const end = close < 0 ? n : close + 3;
const body = text.slice(i + 3, close < 0 ? n : close);
// Java text blocks start after a line break; Kotlin raw strings keep it.
out.push({ kind: "str", text: text.slice(i, end), value: body.replace(/^[ \t]*\r?\n/, ""), start, end, nl });
i = end;
} else if (c === '"' || c === "'") {
let j = i + 1;
let value = "";
while (j < n && text[j] !== c && text[j] !== "\n") {
if (text[j] === "\\" && j + 1 < n) {
const e = text[j + 1]!;
value += e === "n" ? "\n" : e === "t" ? "\t" : e;
j += 2;
} else value += text[j++];
}
const end = Math.min(n, j + 1);
out.push({ kind: "str", text: text.slice(i, end), value, start, end, nl });
i = end;
} else if (c === "`") {
const close = text.indexOf("`", i + 1);
const end = close < 0 || text.slice(i, close).includes("\n") ? i + 1 : close + 1;
out.push({ kind: "id", text: end > i + 1 ? text.slice(i + 1, end - 1) : "`", start, end, nl });
i = end;
} else if (ID_START.test(c)) {
let j = i + 1;
while (j < n && ID_PART.test(text[j]!)) j++;
out.push({ kind: "id", text: text.slice(i, j), start, end: j, nl });
i = j;
} else if (/\d/.test(c)) {
let j = i + 1;
while (j < n && /[\w.]/.test(text[j]!)) j++;
out.push({ kind: "num", text: text.slice(i, j), start, end: j, nl });
i = j;
} else {
out.push({ kind: "p", text: c, start, end: i + 1, nl });
i++;
}
nl = false;
}
return out;
}
const OPEN: Record<string, string> = { "(": ")", "[": "]", "{": "}" };
/** Index of the matching closing bracket for every opening one (unbalanced: the end). */
function matchBrackets(toks: Tok[]): Int32Array {
const match = new Int32Array(toks.length).fill(-1);
const stack: number[] = [];
toks.forEach((t, i) => {
if (t.kind !== "p") return;
if (OPEN[t.text]) stack.push(i);
else if (t.text === ")" || t.text === "]" || t.text === "}") {
// Pop to the matching opener (unclosed ones inside end here too); stray closers are ignored.
for (let k = stack.length - 1; k >= 0; k--) {
if (OPEN[toks[stack[k]!]!.text] === t.text) {
for (const o of stack.splice(k)) match[o] = i;
break;
}
}
}
});
for (const o of stack) match[o] = toks.length;
return match;
}
const CLASS_KW = new Set(["class", "interface", "enum", "record", "object"]);
const MODIFIERS = new Set([
"public",
"private",
"protected",
"internal",
"static",
"final",
"abstract",
"open",
"override",
"const",
"data",
"sealed",
"inline",
"suspend",
"lateinit",
"default",
"synchronized",
"native",
"transient",
"volatile",
"strictfp",
"operator",
"infix",
"external",
"annotation",
"inner",
"value",
"companion",
"fun",
"val",
"var",
]);
const NOT_METHOD = new Set(["if", "for", "while", "switch", "catch", "synchronized", "return", "new", "throw", "when"]);
class Reader {
readonly match: Int32Array;
constructor(readonly toks: Tok[]) {
this.match = matchBrackets(toks);
}
is(i: number, text: string): boolean {
const t = this.toks[i];
return !!t && t.text === text && (t.kind === "p" || t.kind === "id");
}
/** Kotlin: a token on a new line that starts a member ends the previous one. */
startsMember(i: number): boolean {
const t = this.toks[i];
if (!t || !t.nl) return false;
return t.text === "@" || t.text === "}" || (t.kind === "id" && (MODIFIERS.has(t.text) || CLASS_KW.has(t.text)));
}
/** `@Name(args)` at `i` (the `@`); returns the annotation and the next index. */
annotation(i: number): [Annotation, number] | null {
if (!this.is(i, "@") || this.toks[i + 1]?.kind !== "id" || this.is(i + 1, "interface")) return null;
let j = i + 1;
let name = this.toks[j]!.text;
// Kotlin use-site targets: `@field:JsonProperty`.
if (this.is(j + 1, ":") && this.toks[j + 2]?.kind === "id") {
j += 2;
name = this.toks[j]!.text;
}
while (this.is(j + 1, ".") && this.toks[j + 2]?.kind === "id") {
j += 2;
name = this.toks[j]!.text;
}
j++;
const args = new Map<string, Expr>();
if (this.is(j, "(") && !this.toks[j]!.nl) {
const close = this.match[j]!;
const positional: Tok[][] = [];
for (const part of this.split(j + 1, close)) {
if (!part.length) continue;
if (part[0]!.kind === "id" && part[1]?.text === "=" && part[1].kind === "p" && part[2]?.text !== "=")
args.set(part[0]!.text, part.slice(2));
else positional.push(part);
}
// Kotlin `vararg value`: `@GetMapping("/a", "/b")` is an array.
if (positional.length === 1) args.set("value", positional[0]!);
else if (positional.length > 1) {
const p = (text: string, at: Tok): Tok => ({ kind: "p", text, start: at.start, end: at.start, nl: false });
const first = positional[0]![0]!;
args.set("value", [
p("[", first),
...positional.flatMap((x, k) => (k ? [p(",", x[0]!), ...x] : x)),
p("]", first),
]);
}
j = close + 1;
}
return [{ name, args }, j];
}
/** Splits `[from, to)` at top-level commas (brackets and generics nest). */
split(from: number, to: number): Tok[][] {
const parts: Tok[][] = [];
let cur: Tok[] = [];
let angle = 0;
for (let i = from; i < to; i++) {
const t = this.toks[i]!;
if (t.kind === "p" && OPEN[t.text]) {
const close = Math.min(this.match[i]!, to - 1);
for (let k = i; k <= close; k++) cur.push(this.toks[k]!);
i = close;
continue;
}
if (t.kind === "p" && t.text === "<") angle++;
if (t.kind === "p" && t.text === ">" && angle > 0) angle--;
if (t.kind === "p" && t.text === "," && angle === 0) {
parts.push(cur);
cur = [];
continue;
}
cur.push(t);
}
parts.push(cur);
return parts.filter((p, idx) => p.length || idx < parts.length - 1);
}
params(open: number): Param[] {
const close = this.match[open]!;
const out: Param[] = [];
for (const part of this.split(open + 1, close)) {
const annotations: Annotation[] = [];
const rest: Tok[] = [];
const sub = new Reader(part);
for (let i = 0; i < part.length;) {
const a = sub.annotation(i);
if (a) {
annotations.push(a[0]);
i = a[1];
} else rest.push(part[i++]!);
}
const colon = rest.findIndex((t) => t.kind === "p" && t.text === ":");
if (colon >= 0) {
// Kotlin `name: Type = default`.
const head = rest
.slice(0, colon)
.filter((t) => t.kind === "id" && !MODIFIERS.has(t.text) && t.text !== "vararg");
const eq = rest.findIndex((t, k) => k > colon && t.kind === "p" && t.text === "=");
const name = head[head.length - 1]?.text;
if (name) out.push({ name, type: joined(rest.slice(colon + 1, eq < 0 ? undefined : eq)), annotations });
} else {
const toks = rest.filter((t) => !(t.kind === "id" && t.text === "final"));
const last = toks[toks.length - 1];
if (last?.kind === "id") out.push({ name: last.text, type: joined(toks.slice(0, -1)), annotations });
}
}
return out;
}
/** Members of a body `[from, to)`; nested declarations recurse. */
body(from: number, to: number, into: ClassDecl): void {
let annotations: Annotation[] = [];
let header: Tok[] = [];
let declStart = -1;
const reset = () => {
annotations = [];
header = [];
declStart = -1;
};
const flushField = (init: Expr | null) => {
const ids = header.filter((t) => t.kind === "id");
const kw = header.findIndex((t) => t.kind === "id" && (t.text === "val" || t.text === "var"));
const isStatic = into.isObject || ids.some((t) => t.text === "static" || t.text === "const");
if (kw >= 0) {
const name = header[kw + 1];
const colon = header.findIndex((t, k) => k > kw && t.text === ":" && t.kind === "p");
if (name?.kind === "id")
into.fields.push({
name: name.text,
type: colon >= 0 ? joined(header.slice(colon + 1)) : "",
isStatic,
init,
});
} else {
const typeToks = header.filter((t) => !(t.kind === "id" && MODIFIERS.has(t.text)));
const name = typeToks[typeToks.length - 1];
if (name?.kind === "id" && typeToks.length >= 2)
into.fields.push({ name: name.text, type: joined(typeToks.slice(0, -1)), isStatic, init });
}
};
let i = from;
while (i < to) {
const t = this.toks[i]!;
if (header.length && this.startsMember(i) && !this.is(i, "}")) {
flushField(null);
reset();
}
const a = this.annotation(i);
if (a) {
if (declStart < 0) declStart = t.start;
annotations.push(a[0]);
i = a[1];
continue;
}
if (t.kind === "p" && t.text === ";") {
flushField(null);
reset();
i++;
continue;
}
if (declStart < 0) declStart = t.start;
const companion = t.kind === "id" && t.text === "object" && this.is(i + 1, "{");
if (t.kind === "id" && CLASS_KW.has(t.text) && (this.toks[i + 1]?.kind === "id" || companion)) {
const nameTok = companion ? t : this.toks[i + 1]!;
const cls: ClassDecl = {
name: companion ? "Companion" : nameTok.text,
annotations,
nameStart: nameTok.start,
start: declStart,
end: nameTok.end,
isObject: t.text === "object",
methods: [],
fields: [],
ctorParams: [],
classes: [],
};
let j = companion ? i + 1 : i + 2;
if (this.is(j, "<")) {
let depth = 0;
for (; j < to; j++) {
if (this.is(j, "<")) depth++;
else if (this.is(j, ">") && --depth === 0) break;
}
j++;
}
// Kotlin `class A @Inject constructor(…)`.
while (j < to && (this.is(j, "@") || this.toks[j]?.kind === "id") && !this.toks[j]!.nl) {
const ann = this.annotation(j);
if (ann) j = ann[1];
else if (this.toks[j]!.kind === "id" && (MODIFIERS.has(this.toks[j]!.text) || this.is(j, "constructor"))) j++;
else break;
}
if (this.is(j, "(")) {
cls.ctorParams = this.params(j);
j = this.match[j]! + 1;
}
for (; j < to; j++) {
if (this.is(j, "{") || this.is(j, ";") || this.startsMember(j)) break;
if (OPEN[this.toks[j]!.text] && this.toks[j]!.kind === "p") j = this.match[j]!;
}
if (this.is(j, "{")) {
const close = Math.min(this.match[j]!, to);
this.body(j + 1, close, cls);
cls.end = this.toks[close]?.end ?? this.toks[to - 1]!.end;
j = close + 1;
} else cls.end = this.toks[j - 1]!.end;
into.classes.push(cls);
reset();
i = j;
continue;
}
if (
t.kind === "id" &&
this.is(i + 1, "(") &&
!NOT_METHOD.has(t.text) &&
!header.some((h) => h.kind === "p" && (h.text === "=" || h.text === "."))
) {
const params = this.params(i + 1);
let j = this.match[i + 1]! + 1;
for (; j < to; j++) {
if (this.is(j, "{")) {
j = this.match[j]! + 1;
break;
}
if (this.is(j, ";")) {
j++;
break;
}
if (this.startsMember(j)) break;
if (this.toks[j]!.kind === "p" && OPEN[this.toks[j]!.text]) j = this.match[j]!;
}
const end = this.toks[Math.min(j, this.toks.length) - 1]?.end ?? t.end;
into.methods.push({ name: t.text, annotations, params, nameStart: t.start, start: declStart, end });
reset();
i = j;
continue;
}
if (t.kind === "p" && t.text === "=") {
const init: Tok[] = [];
let j = i + 1;
for (; j < to; j++) {
if (this.is(j, ";") || this.is(j, "}")) break;
const prev = this.toks[j - 1]!;
const operand = prev.kind === "p" && /[+.(,=]/.test(prev.text);
if (j > i + 1 && this.startsMember(j) && !operand) break;
if (j > i + 1 && this.toks[j]!.nl && !operand && !/^[+.]$/.test(this.toks[j]!.text)) break;
const tok = this.toks[j]!;
if (tok.kind === "p" && OPEN[tok.text]) {
const close = Math.min(this.match[j]!, to - 1);
for (let k = j; k <= close; k++) init.push(this.toks[k]!);
j = close;
} else init.push(tok);
}
flushField(init);
reset();
i = this.is(j, ";") ? j + 1 : j;
continue;
}
if (t.kind === "p" && OPEN[t.text]) {
// Initializer blocks, enum constant arguments…
i = this.match[i]! + 1;
if (t.text === "{") reset();
continue;
}
if (t.kind === "p" && t.text === ",") {
// Enum constants / `int a, b;`.
reset();
i++;
continue;
}
header.push(t);
i++;
}
if (header.length) flushField(null);
}
}
function joined(toks: Tok[]): string {
return toks.map((t) => t.text).join("");
}
export function parseSource(text: string): SourceFile {
const toks = tokenize(text);
const r = new Reader(toks);
let packageName = "";
const imports: string[] = [];
let i = 0;
// `package a.b;` / `import a.b.C;` (Kotlin: no semicolons; `import a.b.C as D`).
for (;;) {
const t = toks[i];
if (!t || t.kind !== "id" || (t.text !== "package" && t.text !== "import")) {
if (t && r.is(i, "@") && toks[i + 1]?.text === "file") {
const a = r.annotation(i);
if (a) {
i = a[1];
continue;
}
}
break;
}
let j = i + 1;
let name = "";
while (j < toks.length && !toks[j]!.nl && !r.is(j, ";")) {
if (toks[j]!.kind === "id" && toks[j]!.text === "as") break;
name += toks[j]!.text;
j++;
}
while (j < toks.length && !toks[j]!.nl && !r.is(j, ";")) j++;
if (r.is(j, ";")) j++;
if (t.text === "package") packageName = name;
else imports.push(name.replace(/^static/, ""));
i = j;
}
const root: ClassDecl = {
name: "",
annotations: [],
nameStart: 0,
start: 0,
end: text.length,
isObject: true,
methods: [],
fields: [],
ctorParams: [],
classes: [],
};
r.body(i, toks.length, root);
return { packageName, imports, classes: root.classes, fields: root.fields };
}
/** Every class of the file, nested ones included. */
export function allClasses(file: SourceFile): ClassDecl[] {
const out: ClassDecl[] = [];
const walk = (cs: ClassDecl[]) => {
for (const c of cs) {
out.push(c);
walk(c.classes);
}
};
walk(file.classes);
return out;
}
+302
Переглянути файл
@@ -0,0 +1,302 @@
import { describe, expect, it } from "vitest";
import { springAt, springControllers, type Endpoint } from "./endpoints";
import {
appendRequests,
bodyLookup,
findHost,
hostFromConfig,
parseProperties,
parseYaml,
renderRequest,
skeleton,
type SourceReader,
} from "./request";
import { parseSource } from "./source";
const JAVA = `package com.example.demo.web;
import com.example.demo.dto.CreateUser;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
/** Users { not a brace } */
@RestController
@RequestMapping(UserController.BASE + "/users")
public class UserController {
static final String BASE = "/api";
private static final String BY_ID = "/{id}";
private final UserService service;
public UserController(UserService service) { this.service = service; }
@GetMapping
public List<User> list(@RequestParam(defaultValue = "0") int page, @RequestParam("q") String query,
@RequestParam Map<String, String> all) {
return service.list(); // "}" in a comment
}
@GetMapping(BY_ID)
public User get(@PathVariable Long id, @RequestHeader("X-Trace") String trace) {
if (id == null) { throw new IllegalArgumentException("{"); }
return service.get(id);
}
@PostMapping(value = {"", "/new"}, consumes = MediaType.APPLICATION_JSON_VALUE, produces = "application/json")
public User create(@Valid @RequestBody CreateUser body) { return service.create(body); }
@RequestMapping(path = "/{id:\\\\d+}/avatar", method = RequestMethod.PUT)
public void avatar(@PathVariable("id") long id, @RequestBody byte[] data) {}
@DeleteMapping(path = BY_ID, params = "force=true", headers = "X-Api=1")
public void delete(@PathVariable Long id) {}
@org.springframework.web.bind.annotation.PatchMapping("/{id}/name")
public void rename(@PathVariable Long id, @RequestBody Rename rename) {}
public void helper() {}
record Rename(String name, boolean notify) {}
}
`;
const KOTLIN = `package com.example.demo.web
import org.springframework.web.bind.annotation.*
const val API = "/api/v2"
@RestController
@RequestMapping(API)
class OrderController(private val service: OrderService) {
companion object {
const val ORDERS = "/orders"
}
@GetMapping("\${ORDERS}/{id}", ORDERS + "/by-id/{id}")
fun get(@PathVariable id: Long): Order = service.get(id)
@PostMapping(ORDERS)
suspend fun create(@RequestBody order: NewOrder, @RequestParam(required = false) dryRun: Boolean?): Order {
return service.create(order)
}
@RequestMapping(value = [ORDERS + "/search"], method = [RequestMethod.POST, RequestMethod.GET])
fun search(@RequestParam("q") query: String) = service.search(query)
}
data class NewOrder(val item: String, val quantity: Int, val tags: List<String> = emptyList())
`;
function byMethod(endpoints: Endpoint[], name: string): Endpoint {
const e = endpoints.find((x) => x.methodName === name);
if (!e) throw new Error(`no endpoint ${name}`);
return e;
}
describe("Spring mappings (Java)", () => {
const [ctrl] = springControllers(JAVA);
const eps = ctrl!.endpoints;
it("finds the controller and its mapped methods only", () => {
expect(ctrl!.name).toBe("UserController");
expect(eps.map((e) => e.methodName)).toEqual(["list", "get", "create", "avatar", "delete", "rename"]);
expect(JAVA.slice(byMethod(eps, "get").offset).startsWith("get(")).toBe(true);
expect(JAVA.slice(ctrl!.offset).startsWith("UserController {")).toBe(true);
});
it("combines class and method paths, resolving constants of the file", () => {
expect(byMethod(eps, "list").paths).toEqual(["/api/users"]);
expect(byMethod(eps, "get").paths).toEqual(["/api/users/{id}"]);
expect(byMethod(eps, "create").paths).toEqual(["/api/users", "/api/users/new"]);
expect(byMethod(eps, "rename").paths).toEqual(["/api/users/{id}/name"]);
});
it("reads HTTP methods from shortcuts and RequestMapping#method", () => {
expect(eps.map((e) => e.httpMethod)).toEqual(["GET", "GET", "POST", "PUT", "DELETE", "PATCH"]);
});
it("reads request parameters, headers, bodies, consumes and produces", () => {
expect(byMethod(eps, "list").query).toEqual([
{ name: "page", value: "0" },
{ name: "q", value: null },
]);
expect(byMethod(eps, "get").headers).toEqual([{ name: "X-Trace", value: "{{X-Trace}}" }]);
const create = byMethod(eps, "create");
expect(create).toMatchObject({
bodyType: "CreateUser",
consumes: "application/json",
produces: "application/json",
});
expect(byMethod(eps, "delete")).toMatchObject({
query: [{ name: "force", value: "true" }],
headers: [{ name: "X-Api", value: "1" }],
});
});
it("renders requests with path variables, query and JSON body", () => {
const host = "http://localhost:8080";
expect(renderRequest(byMethod(eps, "list"), { host })).toBe(
"### UserController.list\nGET http://localhost:8080/api/users?page=0&q={{q}}\n",
);
expect(renderRequest(byMethod(eps, "get"), { host })).toBe(
"### UserController.get\nGET http://localhost:8080/api/users/{{id}}\nX-Trace: {{X-Trace}}\n",
);
expect(renderRequest(byMethod(eps, "avatar"), { host })).toBe(
"### UserController.avatar\nPUT http://localhost:8080/api/users/{{id}}/avatar\nContent-Type: application/json\n\n{}\n",
);
const file = parseSource(JAVA);
const lookup = (n: string) => file.classes.flatMap((c) => c.classes).find((c) => c.name === n) ?? null;
const rename = byMethod(eps, "rename");
expect(renderRequest(rename, { host, body: skeleton(rename.bodyType!, lookup) })).toBe(
[
"### UserController.rename",
"PATCH http://localhost:8080/api/users/{{id}}/name",
"Content-Type: application/json",
"",
"{",
' "name": "",',
' "notify": false',
"}",
"",
].join("\n"),
);
expect(renderRequest(byMethod(eps, "create"), { host, path: "/api/users/new" })).toBe(
[
"### UserController.create",
"POST http://localhost:8080/api/users/new",
"Accept: application/json",
"Content-Type: application/json",
"",
"{}",
"",
].join("\n"),
);
});
});
describe("Spring mappings (Kotlin)", () => {
const [ctrl] = springControllers(KOTLIN);
const eps = ctrl!.endpoints;
it("finds mappings with companion and top-level constants, arrays and suspend functions", () => {
expect(ctrl!.name).toBe("OrderController");
expect(eps.map((e) => [e.methodName, e.httpMethod, e.paths])).toEqual([
["get", "GET", ["/api/v2/orders/{id}", "/api/v2/orders/by-id/{id}"]],
["create", "POST", ["/api/v2/orders"]],
["search", "POST", ["/api/v2/orders/search"]],
]);
expect(byMethod(eps, "create")).toMatchObject({ bodyType: "NewOrder", query: [{ name: "dryRun", value: null }] });
expect(byMethod(eps, "search").query).toEqual([{ name: "q", value: null }]);
});
it("builds the body from a data class of the file", async () => {
const file = parseSource(KOTLIN);
const lookup = await bodyLookup("/p/src/main/kotlin/com/example/demo/web/OrderController.kt", file, eps, {
read: async () => null,
});
expect(skeleton("NewOrder", lookup)).toEqual({ item: "", quantity: 0, tags: [] });
});
});
describe("non-controllers", () => {
it("ignores classes that are not controllers and files without any", () => {
expect(springControllers('@Service class A { @GetMapping("/x") void a() {} }')).toEqual([]);
expect(springControllers("class A {}")).toEqual([]);
expect(
springControllers('@FeignClient interface C { @GetMapping("/x") String a(); }\n@Controller class B {}'),
).toEqual([{ name: "B", offset: expect.any(Number), range: expect.any(Object), endpoints: [] }]);
});
it("uses GET for a RequestMapping without method and the class-level method otherwise", () => {
const [c] = springControllers(
'@Controller @RequestMapping(value="/a", method=RequestMethod.POST) class A { @RequestMapping("/b") String b() { return ""; } }',
);
expect(c!.endpoints[0]).toMatchObject({ httpMethod: "POST", paths: ["/a/b"] });
const [d] = springControllers('@RestController class D { @RequestMapping("/b") String b() { return ""; } }');
expect(d!.endpoints[0]).toMatchObject({ httpMethod: "GET", paths: ["/b"] });
});
});
describe("server address", () => {
it("reads server.port and the context path from properties", () => {
const props = parseProperties(
"# comment\nserver.port=9090\nserver.servlet.context-path = /shop/\nspring.application.name: demo\n",
);
expect(hostFromConfig([props])).toBe("http://localhost:9090/shop");
});
it("reads nested and flattened keys from YAML's first document", () => {
const yml = parseYaml(
'spring:\n application:\n name: demo # x\nserver:\n port: ${PORT:8181}\n servlet:\n context-path: "/api"\n---\nserver:\n port: 1\n',
);
expect(hostFromConfig([yml])).toBe("http://localhost:8181/api");
expect(hostFromConfig([parseYaml("server.port: 7070\n")])).toBe("http://localhost:7070");
});
it("defaults to localhost:8080 and keeps unknown placeholders out", () => {
expect(hostFromConfig([])).toBe("http://localhost:8080");
expect(hostFromConfig([parseProperties("server.port=${PORT}\n")])).toBe("http://localhost:8080");
expect(hostFromConfig([parseProperties("server.port=0\n")])).toBe("http://localhost:8080");
expect(hostFromConfig([parseProperties("server.port=8443\nserver.ssl.enabled=true\n")])).toBe(
"https://localhost:8443",
);
expect(hostFromConfig([parseYaml("spring:\n webflux:\n base-path: /rx\n")])).toBe("http://localhost:8080/rx");
});
it("looks in the module's src/main/resources", async () => {
const files: Record<string, string> = {
"/ws/api/src/main/resources/application.yml": "server:\n port: 8001\n",
"/ws/src/main/resources/application.properties": "server.port=9000\n",
};
const fs: SourceReader = { read: async (p) => files[p] ?? null };
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:8001");
expect(await findHost("/ws/other/A.java", "/ws", fs)).toBe("http://localhost:9000");
});
});
describe("body types from other files", () => {
it("follows the import to the same source root, then the controller's directory", async () => {
const files: Record<string, string> = {
"/p/src/main/java/com/example/demo/dto/CreateUser.java":
"package com.example.demo.dto;\npublic class CreateUser {\n public static final int MAX = 3;\n private String name;\n private int age;\n private Address address;\n private List<String> roles;\n}\n",
"/p/src/main/java/com/example/demo/dto/Address.java":
"package com.example.demo.dto;\npublic record Address(String city, String zip) {}\n",
};
const fs: SourceReader = { read: async (p) => files[p] ?? null };
const file = parseSource(JAVA);
const eps = springControllers(JAVA)[0]!.endpoints;
const lookup = await bodyLookup("/p/src/main/java/com/example/demo/web/UserController.java", file, eps, fs);
expect(skeleton("CreateUser", lookup)).toEqual({ name: "", age: 0, address: { city: "", zip: "" }, roles: [] });
expect(skeleton("Mono<CreateUser>", lookup)).toMatchObject({ name: "" });
expect(skeleton("List<CreateUser>", lookup)).toEqual([]);
expect(skeleton("Unknown", lookup)).toEqual({});
});
});
describe("appending to the request file", () => {
const block = "### A.a\nGET http://localhost:8080/a\n";
it("starts an empty file with the request", () => {
expect(appendRequests("", [block])).toEqual({ insert: block, from: 0, caret: 8 });
});
it("separates from existing requests with a blank line before ###", () => {
const existing = "### X\nGET http://x\n";
const r = appendRequests(existing, [block, block]);
expect(existing + r.insert).toBe(`${existing}\n${block}\n${block}`);
expect((existing + r.insert).slice(r.caret).startsWith("GET http://localhost:8080/a")).toBe(true);
expect(appendRequests("GET http://x", [block]).insert.startsWith("\n\n###")).toBe(true);
expect(appendRequests("GET http://x\n\n", [block]).insert.startsWith("###")).toBe(true);
});
});
describe("caret lookup", () => {
it("finds the endpoint whose declaration (annotations included) holds the caret, and its controller", () => {
const controllers = springControllers(JAVA);
const at = (needle: string) => springAt(controllers, JAVA.indexOf(needle));
expect(at("@GetMapping(BY_ID)").endpoint?.methodName).toBe("get");
expect(at("service.get(id)").endpoint?.methodName).toBe("get");
expect(at("public void helper").endpoint).toBeNull();
expect(at("public void helper").controller?.name).toBe("UserController");
expect(springAt(controllers, 0)).toEqual({ controller: null, endpoint: null });
});
});
+12
Переглянути файл
@@ -536,6 +536,18 @@ const GUTTER = editorPage({
component: () => <GutterGroup id="run" label="Run configurations and tests" />,
},
{ t: "custom", label: "Bookmarks", component: () => <GutterGroup id="bookmarks" label="Bookmarks" /> },
{
t: "custom",
label: "Implemented and overridden",
keywords: ["implementations", "subclasses", "inheritors"],
component: () => <GutterGroup id="inheritors" label="Implemented and overridden declarations" />,
},
{
t: "custom",
label: "Implementing and overriding",
keywords: ["super method", "overrides", "implements"],
component: () => <GutterGroup id="supers" label="Implementing and overriding methods" />,
},
],
},
],
+47 -12
Переглянути файл
@@ -30,7 +30,9 @@ import {
showContextMenu,
type MenuItem,
T,
copyText,
} from "@/ui";
import { hasPrimaryModifier } from "@/platform/keymap";
import { commitFileDiffData, openDiff, revisionChangeDiffData, withChain } from "./diffInput";
import { collapseLinear, computeGraph, hideLongEdges, laneColor, reachableFrom, type GraphRow } from "./graph";
import {
@@ -54,7 +56,7 @@ import { refLabels, useLogOptions, type LogOptions } from "./logOptions";
import { KIND_COLOR, baseName, formatTime, relTo, type EffectiveKind } from "./model";
import { clearConsole, useVcsConsole } from "./ops";
import { currentRepo, loadBranches, useVcs } from "./store";
import { checkoutBranch, loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps";
import { loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps";
import { branchMenu } from "./branchMenu";
import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches";
import { chooseDateRange, chooseMany, promptText } from "./dialogs";
@@ -167,7 +169,7 @@ function branchDetail(n: BranchTreeNode): string | undefined {
return [b.current ? "current" : "", sync].filter(Boolean).join(" ") || undefined;
}
function BranchesPanel() {
export function BranchesPanel() {
const branches = useVcs((s) => s.branches);
const root = useVcs((s) => s.root);
const branchesLoaded = useVcs((s) => s.branchesLoaded);
@@ -180,12 +182,43 @@ function BranchesPanel() {
const roots = useMemo(() => filterBranchTree(allRoots, query), [allRoots, query]);
// While searching every match is shown expanded.
const searchExpanded = useMemo(() => (query.trim() ? new Set(branchGroupKeys(roots)) : undefined), [roots, query]);
const selected = useMemo(
() => new Set(filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`))),
[filterBranches],
);
// Highlighted rows: a click only selects (IDEA), and only a double click or
// Enter switches the log, so the selection is the pane's own state. It still
// follows the filter when something else changes it (Show Log, the filter bar).
const filterKeys = useMemo(() => filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`)), [filterBranches]);
const filterKey = filterKeys.join("\n");
const [shownKey, setShownKey] = useState(filterKey);
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set(filterKeys));
if (shownKey !== filterKey) {
// The filter changed elsewhere (Show Log, the filter bar): highlight it.
setShownKey(filterKey);
setSelected(new Set(filterKeys));
}
// Branches in tree order, for Copy: full refs, so the folder path comes along
// (`feat/x`, `origin/main`).
const refs = useMemo(() => {
const out: { key: string; ref: string }[] = [];
const walk = (nodes: readonly BranchTreeNode[]) => {
for (const n of nodes) {
if (n.ref) out.push({ key: n.key, ref: n.ref });
walk(n.children ?? []);
}
};
walk(allRoots);
return out;
}, [allRoots]);
const onKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.key !== "c" && e.key !== "C") || !hasPrimaryModifier(e) || e.altKey || e.shiftKey) return;
// The branch search keeps the browser's own copy.
if ((e.target as HTMLElement).closest("input, textarea")) return;
const names = refs.filter((r) => selected.has(r.key)).map((r) => r.ref);
if (!names.length) return;
e.preventDefault();
e.stopPropagation();
void copyText(names.join("\n"));
};
return (
<div className={styles.branches}>
<div className={styles.branches} onKeyDownCapture={onKeyDownCapture}>
<div className={styles.branchesBar} role="toolbar" aria-label="Branches">
<SearchField
className={styles.branchSearch}
@@ -225,12 +258,14 @@ function BranchesPanel() {
}
getDetail={branchDetail}
selected={selected}
onSelectionChange={(sel) => {
// Several selected branches filter the log by all of them.
const refs = [...sel].flatMap((k) => (k === "head" ? ["HEAD"] : k.startsWith("b:") ? [k.slice(2)] : []));
if (refs.length) setLogFilters({ branches: refs });
onSelectionChange={(sel) => setSelected(sel)}
onActivate={(n) => {
// IDEA: double click (or Enter) switches the log to that ref, or to every
// selected one when it is part of a multi-selection; checking out stays in the context menu.
if (!n.ref) return;
const sel = selected.has(n.key) ? refs.filter((r) => selected.has(r.key)).map((r) => r.ref) : [n.ref];
setLogFilters({ branches: sel });
}}
onActivate={(n) => n.branch && !n.branch.current && void checkoutBranch(n.branch.name)}
onContextMenu={(n, _s, e) => {
const items = n?.branch ? branchMenu(n.branch, current) : [];
if (!items.length) return;
+141
Переглянути файл
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Branch, BranchInfo } from "@/bindings";
import { isMacHost } from "@/platform/keymap";
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsLog: () => Promise.resolve({ status: "ok", data: [] }),
vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }),
},
};
});
import { BranchesPanel } from "../GitLog";
import { emptyFilters, useGitLog } from "../logStore";
import { useVcs } from "../store";
const b = (name: string, opts: Partial<Branch> = {}): Branch => ({
name,
remote: false,
current: false,
upstream: null,
ahead: 0,
behind: 0,
lastCommitMs: 0,
commit: "",
...opts,
});
const branches = [b("main", { current: true, commit: "1" }), b("feature", { commit: "2" })];
const head: BranchInfo = {
head: "main",
headCommit: "1",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
};
const writeText = vi.fn().mockResolvedValue(undefined);
/** The copy modifier on this host: ⌘ on macOS, Ctrl elsewhere. */
const cmd = () => (isMacHost() ? { metaKey: true } : { ctrlKey: true });
beforeEach(() => {
writeText.mockClear();
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
useVcs.setState({ root: "/p", repo: "/p", branches, branchesLoaded: true, branch: head });
useGitLog.setState({ filters: emptyFilters() });
});
describe("Git Log branches pane", () => {
it("one click only selects the branch", () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("feature"));
expect(useGitLog.getState().filters.branches).toEqual([]);
expect(screen.getByText("feature").closest("[data-row]")).toHaveAttribute("aria-selected", "true");
});
it("double click switches the log to that branch", () => {
render(<BranchesPanel />);
fireEvent.doubleClick(screen.getByText("feature"));
expect(useGitLog.getState().filters.branches).toEqual(["feature"]);
});
it("double click on the current branch switches to its log too", () => {
render(<BranchesPanel />);
fireEvent.doubleClick(screen.getByText("main"));
expect(useGitLog.getState().filters.branches).toEqual(["main"]);
});
it("double click on HEAD shows HEAD's log", () => {
render(<BranchesPanel />);
fireEvent.doubleClick(screen.getByText("HEAD (Current Branch)"));
expect(useGitLog.getState().filters.branches).toEqual(["HEAD"]);
});
it("Enter switches the log to every selected branch, in tree order", () => {
useVcs.setState({ branches: [b("main", { current: true }), b("beta"), b("alpha")] });
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("beta"));
fireEvent.mouseDown(screen.getByText("alpha"), cmd());
expect(useGitLog.getState().filters.branches).toEqual([]);
fireEvent.keyDown(screen.getByRole("tree", { name: "Branches" }), { key: "Enter" });
expect(useGitLog.getState().filters.branches).toEqual(["alpha", "beta"]);
});
it("follows the log filter when it changes elsewhere", () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("main"));
act(() => useGitLog.setState({ filters: { ...emptyFilters(), branches: ["feature"] } }));
expect(screen.getByText("feature").closest("[data-row]")).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("main").closest("[data-row]")).toHaveAttribute("aria-selected", "false");
});
it("copies the selected branch name", async () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("feature"));
fireEvent.keyDown(screen.getByText("feature"), { key: "c", ...cmd() });
await waitFor(() => expect(writeText).toHaveBeenCalledWith("feature"));
});
it("copies HEAD as HEAD", async () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("HEAD (Current Branch)"));
fireEvent.keyDown(screen.getByText("HEAD (Current Branch)"), { key: "c", ...cmd() });
await waitFor(() => expect(writeText).toHaveBeenCalledWith("HEAD"));
});
it("copies the folder path along with the branch name", async () => {
useVcs.setState({
branches: [b("feat/idea-parity", { current: true, commit: "1" })],
branch: { ...head, head: "feat/idea-parity" },
});
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("idea-parity"));
fireEvent.keyDown(screen.getByText("idea-parity"), { key: "c", ...cmd() });
await waitFor(() => expect(writeText).toHaveBeenCalledWith("feat/idea-parity"));
});
it("copies every selected branch, one per line, in tree order", async () => {
useVcs.setState({ branches: [b("main", { current: true }), b("beta"), b("alpha")] });
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("beta"), cmd());
fireEvent.mouseDown(screen.getByText("alpha"), cmd());
fireEvent.keyDown(screen.getByText("alpha"), { key: "c", ...cmd() });
await waitFor(() => expect(writeText).toHaveBeenCalledWith("alpha\nbeta"));
});
it("leaves Cmd/Ctrl+C in the branch search field alone", async () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("feature"));
fireEvent.keyDown(screen.getByLabelText("Filter branches"), { key: "c", ...cmd() });
await Promise.resolve();
expect(writeText).not.toHaveBeenCalled();
});
});
+19
Переглянути файл
@@ -194,6 +194,7 @@
"Cannot convert encoding": "无法转换编码",
"Cannot create scratch file": "无法创建临时文件",
"Cannot create the HTTP request file": "无法创建 HTTP 请求文件",
"Cannot generate the HTTP request": "无法生成 HTTP 请求",
"Cannot duplicate the data source": "无法复制数据源",
"Cannot execute": "无法执行",
"Cannot execute on {target}": "无法在 {target} 上执行",
@@ -790,6 +791,8 @@
"Generate": "生成",
"Generate DDL to Clipboard": "生成 DDL 到剪贴板",
"Generate DDL to Query Console": "生成 DDL 到查询控制台",
"Generate Request in HTTP Client": "在 HTTP 客户端中生成请求",
"Generate Requests for Controller": "为控制器生成请求",
"Generate…": "生成…",
"Get from Version Control": "从版本控制获取",
"Get from Version Control…": "从版本控制获取…",
@@ -1167,6 +1170,8 @@
"No GitHub repository": "没有 GitHub 仓库",
"No Gradle projects": "没有 Gradle 项目",
"No HTTP request at the caret": "光标处没有 HTTP 请求",
"No Spring controller at the caret": "光标处没有 Spring 控制器",
"No Spring request mapping at the caret": "光标处没有 Spring 请求映射",
"No Maven projects": "没有 Maven 项目",
"No profile for '{name}'": "没有 '{name}' 的分析数据",
"No Python found": "未找到 Python",
@@ -3703,6 +3708,20 @@
"Can't rename '{name}' without the language server: only local variables can be renamed based on syntax": "没有语言服务器时无法重命名 '{name}':基于语法只能重命名局部变量",
"Choose Super Class or Interface ({count} found)": "选择超类或接口(找到 {count} 个)",
"Choose Super Method of {name} ({count} found)": "选择 {name} 的超方法(找到 {count} 个)",
"Choose Implementation of {name} ({count} found)": "选择 {name} 的实现(找到 {count} 个)",
"Choose Subclass of {name} ({count} found)": "选择 {name} 的子类(找到 {count} 个)",
"Choose Overriding Method of {name} ({count} found)": "选择 {name} 的重写方法(找到 {count} 个)",
"Is implemented by {names}": "由 {names} 实现",
"Is subclassed by {names}": "子类:{names}",
"Is implemented in {names}": "在 {names} 中实现",
"Is overridden in {names}": "在 {names} 中重写",
"Implements method in {names}": "实现 {names} 中的方法",
"Overrides method in {names}": "重写 {names} 中的方法",
"{names} and {count} more": "{names} 等另外 {count} 个",
"Implemented and overridden": "已实现和已重写",
"Implementing and overriding": "实现和重写",
"Implemented and overridden declarations": "已被实现和重写的声明",
"Implementing and overriding methods": "实现和重写超类型方法的方法",
"Replace all {count} occurrences": "替换全部 {count} 处",
"↵ Apply · Esc Keep '{name}'": "↵ 应用 · Esc 保留 '{name}'",
" (in {files} files)": "(位于 {files} 个文件中)",
+11 -2
Переглянути файл
@@ -96,11 +96,13 @@ a:hover {
/* Focus rings mark controls only, as in IDEA: lists, trees, tables, menus, popups and other
focusable containers show focus through their selected row instead of a frame. Text fields
aren't listed: their frame shows focus, and an outline here draws a second box inside it. */
never get one, whatever their role (a search box is a combobox): their frame shows focus, and
an outline here draws a second box inside it. `:where` keeps this at the specificity of a bare
`:focus-visible`, so a component's own focus style always wins. */
:focus-visible {
outline: none;
}
:is(
:where(
button,
a[href],
input:is([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="button"], [type="submit"], [type="reset"]),
@@ -115,6 +117,13 @@ a:hover {
[role="combobox"],
[role="slider"],
[role="spinbutton"]
):not(
:where(
input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="button"], [type="submit"], [type="reset"]),
textarea,
[contenteditable="true"],
[contenteditable=""]
)
):focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 11.5C7.98528 11.5 10 9.48528 10 7C10 4.51472 7.98528 2.5 5.5 2.5C3.01472 2.5 1 4.51472 1 7C1 9.48528 3.01472 11.5 5.5 11.5Z" fill="#F2FCF3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 5H4V4H7V5H6V9H7V10H4V9H5V5Z" fill="#208A3C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 6C11.7761 6 12 6.22386 12 6.5V12.2929L13.1464 11.1464C13.3417 10.9512 13.6583 10.9512 13.8536 11.1464C14.0488 11.3417 14.0488 11.6583 13.8536 11.8536L11.8536 13.8536C11.6583 14.0488 11.3417 14.0488 11.1464 13.8536L9.14645 11.8536C8.95118 11.6583 8.95118 11.3417 9.14645 11.1464C9.34171 10.9512 9.65829 10.9512 9.85355 11.1464L11 12.2929V6.5C11 6.22386 11.2239 6 11.5 6Z" fill="#6C707E"/>
<path d="M5.5 1.5C7.91689 1.5 9.96967 3.05893 10.7077 5.22611C10.2828 5.49095 10 5.96245 10 6.5V7C10 4.51472 7.98528 2.5 5.5 2.5C3.01472 2.5 1 4.51472 1 7C1 9.48528 3.01472 11.5 5.5 11.5C6.61537 11.5 7.63597 11.0942 8.4222 10.4222L8.43934 10.4393C8.05008 10.8286 7.91949 11.3786 8.04756 11.8757C7.28593 12.2745 6.41931 12.5 5.5 12.5C2.46243 12.5 0 10.0376 0 7C0 3.96243 2.46243 1.5 5.5 1.5Z" fill="#208A3C"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.3 KiB

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 11.5C7.98528 11.5 10 9.48528 10 7C10 4.51472 7.98528 2.5 5.5 2.5C3.01472 2.5 1 4.51472 1 7C1 9.48528 3.01472 11.5 5.5 11.5Z" fill="#253627"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 5H4V4H7V5H6V9H7V10H4V9H5V5Z" fill="#57965C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 6C11.7761 6 12 6.22386 12 6.5V12.2929L13.1464 11.1464C13.3417 10.9512 13.6583 10.9512 13.8536 11.1464C14.0488 11.3417 14.0488 11.6583 13.8536 11.8536L11.8536 13.8536C11.6583 14.0488 11.3417 14.0488 11.1464 13.8536L9.14645 11.8536C8.95118 11.6583 8.95118 11.3417 9.14645 11.1464C9.34171 10.9512 9.65829 10.9512 9.85355 11.1464L11 12.2929V6.5C11 6.22386 11.2239 6 11.5 6Z" fill="#CED0D6"/>
<path d="M5.5 1.5C7.91689 1.5 9.96967 3.05893 10.7077 5.22611C10.2828 5.49095 10 5.96245 10 6.5V7C10 4.51472 7.98528 2.5 5.5 2.5C3.01472 2.5 1 4.51472 1 7C1 9.48528 3.01472 11.5 5.5 11.5C6.61537 11.5 7.63597 11.0942 8.4222 10.4222L8.43934 10.4393C8.05008 10.8286 7.91949 11.3786 8.04756 11.8757C7.28593 12.2745 6.41931 12.5 5.5 12.5C2.46243 12.5 0 10.0376 0 7C0 3.96243 2.46243 1.5 5.5 1.5Z" fill="#57965C"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.3 KiB

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7C10 4.51472 7.98528 2.5 5.5 2.5C3.01472 2.5 1 4.51472 1 7C1 9.48528 3.01472 11.5 5.5 11.5C7.98528 11.5 10 9.48528 10 7Z" fill="#F2FCF3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 5H4V4H7V5H6V9H7V10H4V9H5V5Z" fill="#208A3C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 8C11.2239 8 11 7.77614 11 7.5L11 1.70711L9.85355 2.85355C9.65829 3.04882 9.34171 3.04882 9.14645 2.85355C8.95118 2.65829 8.95118 2.34171 9.14645 2.14645L11.1464 0.146447C11.3417 -0.0488155 11.6583 -0.0488155 11.8536 0.146447L13.8536 2.14645C14.0488 2.34171 14.0488 2.65829 13.8536 2.85355C13.6583 3.04882 13.3417 3.04882 13.1464 2.85355L12 1.70711L12 7.5C12 7.77614 11.7761 8 11.5 8Z" fill="#DB3B4B"/>
<path d="M10 7C10 9.48528 7.98528 11.5 5.5 11.5C3.01472 11.5 1 9.48528 1 7C1 4.51472 3.01472 2.5 5.5 2.5C6.61537 2.5 7.63597 2.90579 8.4222 3.5778L8.43934 3.56066C8.05008 3.1714 7.91948 2.62142 8.04756 2.12432C7.28593 1.72554 6.41931 1.5 5.5 1.5C2.46243 1.5 0 3.96243 0 7C0 10.0376 2.46243 12.5 5.5 12.5C7.91689 12.5 9.96967 10.9411 10.7077 8.7739C10.2828 8.50906 10 8.03755 10 7.5V7Z" fill="#208A3C"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.3 KiB

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7C10 4.51472 7.98528 2.5 5.5 2.5C3.01472 2.5 1 4.51472 1 7C1 9.48528 3.01472 11.5 5.5 11.5C7.98528 11.5 10 9.48528 10 7Z" fill="#253627"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 5H4V4H7V5H6V9H7V10H4V9H5V5Z" fill="#57965C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 8C11.2239 8 11 7.77614 11 7.5L11 1.70711L9.85355 2.85355C9.65829 3.04882 9.34171 3.04882 9.14645 2.85355C8.95118 2.65829 8.95118 2.34171 9.14645 2.14645L11.1464 0.146447C11.3417 -0.0488155 11.6583 -0.0488155 11.8536 0.146447L13.8536 2.14645C14.0488 2.34171 14.0488 2.65829 13.8536 2.85355C13.6583 3.04882 13.3417 3.04882 13.1464 2.85355L12 1.70711L12 7.5C12 7.77614 11.7761 8 11.5 8Z" fill="#DB5C5C"/>
<path d="M10 7C10 9.48528 7.98528 11.5 5.5 11.5C3.01472 11.5 1 9.48528 1 7C1 4.51472 3.01472 2.5 5.5 2.5C6.61537 2.5 7.63597 2.90579 8.4222 3.5778L8.43934 3.56066C8.05008 3.1714 7.91948 2.62142 8.04756 2.12432C7.28593 1.72554 6.41931 1.5 5.5 1.5C2.46243 1.5 0 3.96243 0 7C0 10.0376 2.46243 12.5 5.5 12.5C7.91689 12.5 9.96967 10.9411 10.7077 8.7739C10.2828 8.50906 10 8.03755 10 7.5V7Z" fill="#57965C"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.3 KiB

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7C10 9.48528 7.98528 11.5 5.5 11.5C3.01472 11.5 1 9.48528 1 7C1 4.51472 3.01472 2.5 5.5 2.5C7.98528 2.5 10 4.51472 10 7Z" fill="#EDF3FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 6C11.7761 6 12 6.22386 12 6.5V12.2929L13.1464 11.1464C13.3417 10.9512 13.6583 10.9512 13.8536 11.1464C14.0488 11.3417 14.0488 11.6583 13.8536 11.8536L11.8536 13.8536C11.6583 14.0488 11.3417 14.0488 11.1464 13.8536L9.14645 11.8536C8.95118 11.6583 8.95118 11.3417 9.14645 11.1464C9.34171 10.9512 9.65829 10.9512 9.85355 11.1464L11 12.2929V6.5C11 6.22386 11.2239 6 11.5 6Z" fill="#6C707E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.4918 10C3.72951 10 2.5 8.64917 2.5 7.01657V7C2.5 5.3674 3.7459 4 5.5082 4C7.27049 4 8.5 5.35083 8.5 6.98343V7C8.5 8.6326 7.2541 10 5.4918 10ZM5.5082 9.05525C6.64754 9.05525 7.44262 8.14365 7.44262 7.01657V7C7.44262 5.87293 6.63115 4.94475 5.4918 4.94475C4.35246 4.94475 3.55738 5.85635 3.55738 6.98343V7C3.55738 8.12707 4.36885 9.05525 5.5082 9.05525Z" fill="#3574F0"/>
<path d="M5.5 1.5C7.91689 1.5 9.96967 3.05893 10.7077 5.22611C10.2828 5.49095 10 5.96245 10 6.5V7C10 4.51472 7.98528 2.5 5.5 2.5C3.01472 2.5 1 4.51472 1 7C1 9.48528 3.01472 11.5 5.5 11.5C6.61537 11.5 7.63597 11.0942 8.4222 10.4222L8.43934 10.4393C8.05008 10.8286 7.91949 11.3786 8.04756 11.8757C7.28593 12.2745 6.41931 12.5 5.5 12.5C2.46243 12.5 0 10.0376 0 7C0 3.96243 2.46243 1.5 5.5 1.5Z" fill="#3574F0"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.6 KiB

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7C10 9.48528 7.98528 11.5 5.5 11.5C3.01472 11.5 1 9.48528 1 7C1 4.51472 3.01472 2.5 5.5 2.5C7.98528 2.5 10 4.51472 10 7Z" fill="#25324D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 6C11.7761 6 12 6.22386 12 6.5V12.2929L13.1464 11.1464C13.3417 10.9512 13.6583 10.9512 13.8536 11.1464C14.0488 11.3417 14.0488 11.6583 13.8536 11.8536L11.8536 13.8536C11.6583 14.0488 11.3417 14.0488 11.1464 13.8536L9.14645 11.8536C8.95118 11.6583 8.95118 11.3417 9.14645 11.1464C9.34171 10.9512 9.65829 10.9512 9.85355 11.1464L11 12.2929V6.5C11 6.22386 11.2239 6 11.5 6Z" fill="#CED0D6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.4918 10C3.72951 10 2.5 8.64917 2.5 7.01657V7C2.5 5.3674 3.7459 4 5.5082 4C7.27049 4 8.5 5.35083 8.5 6.98343V7C8.5 8.6326 7.2541 10 5.4918 10ZM5.5082 9.05525C6.64754 9.05525 7.44262 8.14365 7.44262 7.01657V7C7.44262 5.87293 6.63115 4.94475 5.4918 4.94475C4.35246 4.94475 3.55738 5.85635 3.55738 6.98343V7C3.55738 8.12707 4.36885 9.05525 5.5082 9.05525Z" fill="#548AF7"/>
<path d="M5.5 1.5C7.91689 1.5 9.96967 3.05893 10.7077 5.22611C10.2828 5.49095 10 5.96245 10 6.5V7C10 4.51472 7.98528 2.5 5.5 2.5C3.01472 2.5 1 4.51472 1 7C1 9.48528 3.01472 11.5 5.5 11.5C6.61537 11.5 7.63597 11.0942 8.4222 10.4222L8.43934 10.4393C8.05008 10.8286 7.91949 11.3786 8.04756 11.8757C7.28593 12.2745 6.41931 12.5 5.5 12.5C2.46243 12.5 0 10.0376 0 7C0 3.96243 2.46243 1.5 5.5 1.5Z" fill="#548AF7"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.6 KiB

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7C10 9.48528 7.98528 11.5 5.5 11.5C3.01472 11.5 1 9.48528 1 7C1 4.51472 3.01472 2.5 5.5 2.5C7.98528 2.5 10 4.51472 10 7Z" fill="#EDF3FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.4918 10C3.72951 10 2.5 8.64917 2.5 7.01657V7C2.5 5.3674 3.7459 4 5.5082 4C7.27049 4 8.5 5.35083 8.5 6.98343V7C8.5 8.6326 7.2541 10 5.4918 10ZM5.5082 9.05525C6.64754 9.05525 7.44262 8.14365 7.44262 7.01657V7C7.44262 5.87293 6.63115 4.94475 5.4918 4.94475C4.35246 4.94475 3.55738 5.85635 3.55738 6.98343V7C3.55738 8.12707 4.36885 9.05525 5.5082 9.05525Z" fill="#3574F0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 8C11.2239 8 11 7.77614 11 7.5L11 1.70711L9.85355 2.85355C9.65829 3.04882 9.34171 3.04882 9.14645 2.85355C8.95118 2.65829 8.95118 2.34171 9.14645 2.14645L11.1464 0.146447C11.3417 -0.0488155 11.6583 -0.0488155 11.8536 0.146447L13.8536 2.14645C14.0488 2.34171 14.0488 2.65829 13.8536 2.85355C13.6583 3.04882 13.3417 3.04882 13.1464 2.85355L12 1.70711L12 7.5C12 7.77614 11.7761 8 11.5 8Z" fill="#DB3B4B"/>
<path d="M10 7C10 9.48528 7.98528 11.5 5.5 11.5C3.01472 11.5 1 9.48528 1 7C1 4.51472 3.01472 2.5 5.5 2.5C6.61537 2.5 7.63597 2.90579 8.4222 3.5778L8.43934 3.56066C8.05008 3.1714 7.91948 2.62142 8.04756 2.12432C7.28593 1.72554 6.41931 1.5 5.5 1.5C2.46243 1.5 0 3.96243 0 7C0 10.0376 2.46243 12.5 5.5 12.5C7.91689 12.5 9.96967 10.9411 10.7077 8.7739C10.2828 8.50906 10 8.03755 10 7.5V7Z" fill="#3574F0"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.6 KiB

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7C10 9.48528 7.98528 11.5 5.5 11.5C3.01472 11.5 1 9.48528 1 7C1 4.51472 3.01472 2.5 5.5 2.5C7.98528 2.5 10 4.51472 10 7Z" fill="#25324D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.4918 10C3.72951 10 2.5 8.64917 2.5 7.01657V7C2.5 5.3674 3.7459 4 5.5082 4C7.27049 4 8.5 5.35083 8.5 6.98343V7C8.5 8.6326 7.2541 10 5.4918 10ZM5.5082 9.05525C6.64754 9.05525 7.44262 8.14365 7.44262 7.01657V7C7.44262 5.87293 6.63115 4.94475 5.4918 4.94475C4.35246 4.94475 3.55738 5.85635 3.55738 6.98343V7C3.55738 8.12707 4.36885 9.05525 5.5082 9.05525Z" fill="#548AF7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 8C11.2239 8 11 7.77614 11 7.5L11 1.70711L9.85355 2.85355C9.65829 3.04882 9.34171 3.04882 9.14645 2.85355C8.95118 2.65829 8.95118 2.34171 9.14645 2.14645L11.1464 0.146447C11.3417 -0.0488155 11.6583 -0.0488155 11.8536 0.146447L13.8536 2.14645C14.0488 2.34171 14.0488 2.65829 13.8536 2.85355C13.6583 3.04882 13.3417 3.04882 13.1464 2.85355L12 1.70711L12 7.5C12 7.77614 11.7761 8 11.5 8Z" fill="#DB5C5C"/>
<path d="M10 7C10 9.48528 7.98528 11.5 5.5 11.5C3.01472 11.5 1 9.48528 1 7C1 4.51472 3.01472 2.5 5.5 2.5C6.61537 2.5 7.63597 2.90579 8.4222 3.5778L8.43934 3.56066C8.05008 3.1714 7.91948 2.62142 8.04756 2.12432C7.28593 1.72554 6.41931 1.5 5.5 1.5C2.46243 1.5 0 3.96243 0 7C0 10.0376 2.46243 12.5 5.5 12.5C7.91689 12.5 9.96967 10.9411 10.7077 8.7739C10.2828 8.50906 10 8.03755 10 7.5V7Z" fill="#548AF7"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.6 KiB

+4
Переглянути файл
@@ -322,6 +322,10 @@ const ICONS = {
"gutter-run-all": "actions/runAll",
"gutter-run-passed": "gutter/runSuccess",
"gutter-run-failed": "gutter/runError",
"gutter-implemented": "gutter/implementedMethod",
"gutter-implementing": "gutter/implementingMethod",
"gutter-overridden": "gutter/overridenMethod",
"gutter-overriding": "gutter/overridingMethod",
stop: "run/stop",
rerun: "run/rerun",
"rerun-filled": "run/rerun_stroke",