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

..
Автор SHA1 Повідомлення Дата
l0ng-ai 93cccb9bb5 fix(navigation): show usages on a declaration a plugin's index links elsewhere
A plugin's document index can link a declaration's name elsewhere: MyBatis Duo links a mapper method to its XML statement. Go to Declaration followed such a link at once, in the editor and in the host, so ⌘B on the method jumped to the XML instead of showing its usages as it does on any declaration (and did for plugins that answer through a definition provider). When the syntax tree puts the caret on a declaration's name, the link is no longer followed directly: the language server's answer comes along and the usages show, the plugin's target among them. Elsewhere plugin links are followed as before, without waiting for the language server.

Fixes #3207
2026-11-14 02:43:03 +08:00
261 змінених файлів з 673 додано та 7374 видалено

No files matched your search

+1 -1
Переглянути файл
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=271.2085.0
KOTLIN_LSP=263.7084.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
+1 -1
Переглянути файл
@@ -119,7 +119,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
+1 -7
Переглянути файл
@@ -291,7 +291,7 @@ pub enum NewTaskApprovals {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on [`Self::address`] while the IDE runs.
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
@@ -300,9 +300,6 @@ pub struct McpServerSettings {
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
/// address of this computer, or `0.0.0.0` / `::` for every address.
pub address: String,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
@@ -315,8 +312,6 @@ pub struct McpServerSettings {
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
/// The default of [`McpServerSettings::address`]: this computer only.
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
impl Default for McpServerSettings {
fn default() -> Self {
@@ -325,7 +320,6 @@ impl Default for McpServerSettings {
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
address: DEFAULT_MCP_ADDRESS.to_owned(),
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
+12 -46
Переглянути файл
@@ -324,17 +324,6 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
/// Connection target: a data source id (its configured database), or
/// `<id>:<database>` for another database of the same server. Data source ids
/// never contain `:`.
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
fn tunneled(driver: DbDriver) -> bool {
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
}
/// The database host and port the SSH host forwards to.
fn tunnel_target(source: &DataSource) -> (&str, u16) {
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
}
pub fn split_target(target: &str) -> (&str, Option<&str>) {
match target.split_once(':') {
Some((id, db)) if !db.is_empty() => (id, Some(db)),
@@ -666,7 +655,9 @@ impl DbManager {
ssh_secret: Option<String>,
shared: bool,
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
return Ok((source.clone(), None));
};
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
@@ -677,25 +668,8 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (target_host, target_port) = tunnel_target(source);
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
/// opened with the settings typed in the data source.
async fn ssh_session(
&self,
source: &DataSource,
ssh: &DbSsh,
ssh_secret: Option<String>,
) -> IdeResult<Arc<ide_ssh::Session>> {
let config = ssh.config.trim();
Ok(if config.is_empty() {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
@@ -714,22 +688,14 @@ impl DbManager {
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
})
}
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
/// the database host and port, without logging in to the database.
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
let source = &source.resolved()?;
let ssh = source
.ssh
.as_ref()
.filter(|s| s.enabled && tunneled(source.driver))
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (host, port) = tunnel_target(source);
drop(Tunnel::open(session.clone(), host, port).await?);
Ok(format!("{} reaches {host}:{port}", session.label()))
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
-96
Переглянути файл
@@ -1,96 +0,0 @@
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
//! the SSH host reaching the database host and port, with no database login.
use ide_core::error::ErrorKind;
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
use ide_ssh::{SshFiles, SshManager};
use std::sync::Arc;
use tokio::net::TcpListener;
struct Env {
_tmp: tempfile::TempDir,
db: DbManager,
ssh_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
root.join("ssh-secrets.json"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
Env { _tmp: tmp, db, ssh_port }
}
impl Env {
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
fn source(&self, db_port: u16) -> DataSource {
DataSource {
id: "pg".into(),
name: "pg".into(),
driver: DbDriver::Postgres,
host: "127.0.0.1".into(),
port: Some(db_port),
user: "app".into(),
ssh: Some(DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth: DbSshAuth::Password,
..Default::default()
}),
..Default::default()
}
}
}
/// A port something listens on (and never speaks a database protocol).
async fn listening() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_tunnel_is_tested_without_a_database_login() {
let env = env().await;
let (_db, db_port) = listening().await;
let source = env.source(db_port);
// First contact: the host key has to be trusted, as with Test Connection.
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
env.db.trust_ssh_host("pg").await.unwrap();
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
let env = env().await;
let (listener, closed) = listening().await;
drop(listener);
let source = env.source(closed);
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
env.db.trust_ssh_host("pg").await.unwrap();
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
}
#[tokio::test]
async fn a_source_without_a_tunnel_has_nothing_to_test() {
let env = env().await;
let mut source = env.source(5432);
source.ssh.as_mut().unwrap().enabled = false;
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
-15
Переглянути файл
@@ -42,10 +42,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_test_tunnel", |c| async move {
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
test_tunnel(&c.host, &c.window, source, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
@@ -216,17 +212,6 @@ pub async fn test(
db.test(&source, password, ssh_secret).await
}
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
pub async fn test_tunnel(
host: &Host,
window: &str,
source: DataSource,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test_tunnel(&source, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
+2 -8
Переглянути файл
@@ -74,15 +74,9 @@ pub async fn lsp_completion(
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
.await
.unwrap_or_default();
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return if covered {
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
} else {
result
};
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
+1 -12
Переглянути файл
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
@@ -89,16 +89,6 @@ pub async fn tools_install(
Ok(info)
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
let tools = tools(host, window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Removes a tool the IDE installed.
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
let tools = tools(host, window)?;
@@ -195,7 +185,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
});
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
d.add("jdk_download_releases", |c| async move {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
-21
Переглянути файл
@@ -22,27 +22,6 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
.await
}
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
/// to no repository are ignored, and an empty list is the full status.
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status_paths(&paths)
})
.await
}
mod commands;
pub use commands::*;
+1 -1
Переглянути файл
@@ -1507,7 +1507,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
+13 -77
Переглянути файл
@@ -1642,9 +1642,6 @@ impl HostApi for PluginApi {
) -> Result<(), HostError> {
self.set_decorations(path, set, Vec::new(), item, true)
}
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
self.set_file_icon_decorations(set, items)
}
fn vcs_log(
&self,
root: &Path,
@@ -4127,9 +4124,6 @@ impl PluginApi {
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4138,14 +4132,24 @@ impl PluginApi {
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self.decoration_root(path);
let root = self
.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
Some(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path.to_path_buf()),
set_id: set.into(),
tree,
items,
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4173,60 +4177,6 @@ impl PluginApi {
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
fn decoration_root(&self, path: &Path) -> PathBuf {
self.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
}
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
if set.is_empty() || set.len() > 128 {
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
}
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
}
let mut by_path = BTreeMap::new();
for (path, icon) in items {
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
by_path.insert(path, icon);
}
let mut all = self.shared.decorations.lock();
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
sets.retain(|s| !(s.tree && s.set_id == set));
for (path, icon) in by_path {
let root = self.decoration_root(&path);
sets.push(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path),
set_id: set.into(),
tree: true,
items: Vec::new(),
file: Some(ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: None,
note: None,
icon: Some(icon),
}),
});
}
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
}
all.insert(self.id().into(), sets.clone());
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
}
fn decoration_style(style: &str) -> bool {
matches!(
@@ -4380,8 +4330,7 @@ mod workbench_tests {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None,
icon: None,
note: None
})
)
.is_err()
@@ -4391,7 +4340,6 @@ mod workbench_tests {
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
icon: None,
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
@@ -4405,18 +4353,6 @@ mod workbench_tests {
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.file_icon_decorations(
"icons",
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
)
.unwrap();
let icons = api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "icons")
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
.collect::<Vec<_>>();
assert_eq!(icons.len(), 2);
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+2 -25
Переглянути файл
@@ -396,37 +396,15 @@ impl PluginHost {
}
}
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
self.provider_plugins(kind, &doc.language)
.into_iter()
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
self.provide_completion_for_editor(doc, at, trigger).1
}
pub(crate) fn provide_completion_for_editor(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> (bool, Vec<(String, CompletionItem)>) {
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
let items = self
.feature_documents(doc, Some(at))
self.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
if self.has_document_providers(ProviderKind::Completion, &doc) {
covered = true;
}
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
@@ -441,8 +419,7 @@ impl PluginHost {
Some((p, i))
})
})
.collect();
(covered, items)
.collect()
}
pub fn provide_code_actions(
+5 -20
Переглянути файл
@@ -743,7 +743,6 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
#[serde(default)]
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
@@ -755,19 +754,13 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
/// icons still win).
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
if d.patterns.is_empty() {
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
} else {
file_patterns(&d.patterns, &what)?;
}
file_patterns(&d.patterns, &what)?;
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
@@ -776,16 +769,8 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
let revision = blake3::hash(&bytes).to_hex().to_string();
let json = serde_json::json!({
"id": d.id,
"patterns": d.patterns,
"dark": d.icon_dark.is_some(),
"revision": revision,
});
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
let mut out = UiDecl::new(d.id.clone(), label, json);
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
out.files = files;
out.assets = assets;
Ok(out)
+1 -2
Переглянути файл
@@ -270,8 +270,7 @@ impl Dispatcher {
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
ops::register_all(&mut d);
d
}
-1
Переглянути файл
@@ -475,7 +475,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_status_paths"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
-15
Переглянути файл
@@ -99,21 +99,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
let toml_path = root.join("a.toml");
std::fs::write(&toml_path, "k = 1\n").unwrap();
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
assert_eq!(
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
.await
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Unavailable
);
let sql_path = root.join("q.sql");
std::fs::write(&sql_path, "SELECT 1;").unwrap();
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
assert_eq!(
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
.parameters,
-37
Переглянути файл
@@ -3760,43 +3760,6 @@ path = "templates/new.draw"
assert!(f.host.web_page("web-demo", "draw").is_err());
}
#[test]
fn file_icons_without_patterns_require_api_1_8_and_project_assets() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("dynamic-icons");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(pkg.join("icons/helm-dir.svg"), r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>"#)
.unwrap();
let write_manifest = |api: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "dynamic-icons"
name = "Dynamic Icons"
version = "0.1.0"
engines.openidea = "{api}"
[[contributes.fileIcons]]
id = "helm-dir"
icon = "icons/helm-dir.svg"
"#
),
)
.unwrap();
};
write_manifest("^1.7");
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("1.8"), "{err:?}");
write_manifest("^1.8");
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let assets = f.host.assets("dynamic-icons", "fileIcon/helm-dir/").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<circle"));
}
#[test]
fn file_templates_fixture_installs_without_write_capabilities() {
let f = fixture(Limits::default());
+16 -49
Переглянути файл
@@ -367,7 +367,6 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
use ide_core::{Diagnostic, Pos, Range, Severity};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let tmp = tempfile::tempdir().unwrap();
let desktop = Arc::new(
@@ -427,70 +426,42 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
"severity": "warning", "message": message, "source": "remote-check", "code": null
}]})
};
let publish = |path: &Path, source: &str, message: Option<&str>| {
let items = message
.map(|message| Diagnostic {
range: Range::new(Pos::new(0, 1), Pos::new(0, 3)),
severity: Severity::Warning,
message: message.into(),
source: Some(source.into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.into_iter()
.collect();
host.project("main").unwrap().sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: source.into(),
items,
});
};
let files = json!([
problem(&remote_file, "remote warning"),
problem(&other_file, "other warning"),
problem(&outside_file, "outside warning")
]);
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
// UI reports are not the source of the plugin's host-owned snapshot.
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
publish(&remote_file, "remote-check", Some("remote warning"));
publish(&other_file, "remote-check", Some("other warning"));
publish(&outside_file, "remote-check", Some("outside warning"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("other warning"), "{problems}");
assert!(problems.contains("outside warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
for path in ["remote.txt", remote_file.to_str().unwrap()] {
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(!problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
}
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
publish(&remote_file, "second-check", Some("second warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
publish(&remote_file, "remote-check", None);
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(!problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
publish(&remote_file, "second-check", Some("replacement warning"));
invoke(
&peer,
"mcp_report_problems",
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
)
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("replacement warning"));
assert!(!problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
publish(&remote_file, "second-check", None);
publish(&other_file, "remote-check", None);
publish(&outside_file, "remote-check", None);
assert!(!problems.as_str().unwrap().contains("other warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
@@ -542,11 +513,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
stream.read_to_string(&mut response).await.unwrap();
assert!(response.contains("You can close this tab"));
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
publish(&remote_file, "remote-check", Some("remote warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
invoke(&peer, "close_project", json!({})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
publish(&remote_file, "remote-check", Some("remote warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
peer.kill();
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
let plugin_host = plugins.host().clone();
@@ -566,11 +537,7 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
})
.await
.unwrap();
// Disconnect clears desktop reports, not the still-live host project snapshot.
let problems = result.as_deref().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("remote-check"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
assert_eq!(result.as_deref(), Some("[]"));
plugins.host().shutdown();
desktop.shutdown();
}
-115
Переглянути файл
@@ -341,7 +341,6 @@ async fn local_deployment_runs_on_the_project_host() {
"db_save_sources",
"db_set_password",
"db_test",
"db_test_tunnel",
"db_set_ssh_secret",
"db_trust_ssh_host",
"db_connected",
@@ -482,117 +481,3 @@ async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
assert!(rpc::client_commands().contains(&"term_spawn"));
plugins.host().shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn renames_a_branch_with_the_clients_argument_names() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir(&root).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git").args(args).current_dir(&root).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
};
git(&["init", "-q", "-b", "main"]);
std::fs::write(root.join("a.txt"), "a\n").unwrap();
git(&["add", "."]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]);
git(&["branch", "feature"]);
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
invoke(&peer, "open_project", json!({"path": root})).await.unwrap();
invoke(&peer, "project_trust_set", json!({"path": root, "trusted": true, "folder": false})).await.unwrap();
// The argument names the desktop client sends (`vcsRenameBranch` in src/bindings.ts).
invoke(
&peer,
"vcs_rename_branch",
json!({"repo": null, "from": "feature", "to": "renamed", "unsetUpstream": false}),
)
.await
.unwrap();
assert_eq!(
git(&["branch", "--format=%(refname:short)", "--list"]).lines().collect::<Vec<_>>(),
["main", "renamed"]
);
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
/// A handler reads its arguments by the names the desktop client sends: `src/bindings.ts`
/// passes them in camelCase, and a name the client never sends reads as `null` (an
/// `Option` silently `None`, anything else an "invalid type: null" error).
#[test]
fn handlers_read_the_argument_names_the_client_sends() {
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let bindings = std::fs::read_to_string(repo.join("src/bindings.ts")).unwrap();
let mut sent = HashMap::<&str, BTreeSet<&str>>::new();
for part in bindings.split("__TAURI_INVOKE(\"").skip(1) {
let (command, rest) = part.split_once('"').unwrap();
let args = rest.strip_prefix(", {").and_then(|r| r.split_once('}')).map_or("", |(args, _)| args);
let names = args.split(',').map(|a| a.split(':').next().unwrap().trim()).filter(|a| !a.is_empty());
sent.insert(command, names.collect());
}
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let mut files = Vec::new();
sources(&repo.join("crates/ide-host/src"), &mut files);
let mut checked = 0;
let mut wrong = Vec::new();
for file in files {
let src = std::fs::read_to_string(&file).unwrap();
// `.add("name", …)`, also with the name on the next line.
let starts: Vec<usize> = src
.match_indices(".add(")
.map(|(i, m)| i + m.len())
.filter_map(|i| src[i..].trim_start().strip_prefix('"').map(|rest| src.len() - rest.len()))
.collect();
for (n, &start) in starts.iter().enumerate() {
let body = &src[start..starts.get(n + 1).copied().unwrap_or(src.len())];
let (command, body) = body.split_once('"').unwrap();
let Some(sent) = sent.get(command) else { continue };
checked += 1;
for reader in ["c.arg", "c.sender", "c.channel"] {
for (at, _) in body.match_indices(reader) {
let rest = &body[at + reader.len()..];
let rest = match rest.strip_prefix("::<") {
Some(generic) => &generic[generic.find(">(").unwrap() + 1..],
None => rest,
};
let Some(name) = rest.strip_prefix("(\"").and_then(|r| r.split_once('"')).map(|(n, _)| n) else {
continue;
};
if !sent.contains(name) {
wrong.push(format!("{command} reads `{name}`; the client sends {sent:?}"));
}
}
}
}
}
assert!(checked > 400, "found only {checked} handlers with client bindings");
assert!(wrong.is_empty(), "{wrong:#?}");
}
-1
Переглянути файл
@@ -172,7 +172,6 @@ async fn restricted_vcs_commands_run_no_program_of_the_repository() {
}
let calls: Vec<Call> = vec![
call!("vcs_status", "", ops::vcs::status(host, w)),
call!("vcs_status_paths", "", ops::vcs::status_paths(host, w, vec![file.clone()])),
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
-26
Переглянути файл
@@ -40,7 +40,6 @@ const BUILTIN: &[(&str, &str)] = &[
("groovy", include_str!("../../../languages/groovy.toml")),
("haskell", include_str!("../../../languages/haskell.toml")),
("hcl", include_str!("../../../languages/hcl.toml")),
("helm-template", include_str!("../../../languages/helm-template.toml")),
("html", include_str!("../../../languages/html.toml")),
("http", include_str!("../../../languages/http.toml")),
("ignore", include_str!("../../../languages/ignore.toml")),
@@ -549,7 +548,6 @@ mod tests {
"Groovy",
"Haskell",
"Haxe",
"Helm Template",
"HXML",
"HTTP",
"IDL",
@@ -843,30 +841,6 @@ mod tests {
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
assert_eq!(detect("common.gotmpl"), "helm-template");
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
assert_ne!(detect("site/index.tpl"), "helm-template");
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
+1 -19
Переглянути файл
@@ -5,7 +5,6 @@
use serde::{Deserialize, Serialize};
use std::io;
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
/// The discovery file, relative to the app data directory.
@@ -40,9 +39,6 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
#[serde(default = "loopback")]
pub address: IpAddr,
pub port: u16,
pub token: String,
/// The IDE's process.
@@ -51,10 +47,6 @@ pub struct Discovery {
pub version: String,
}
fn loopback() -> IpAddr {
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
impl Discovery {
pub fn read(path: &Path) -> io::Result<Self> {
let bytes = std::fs::read(path)?;
@@ -111,13 +103,7 @@ mod tests {
fn round_trips_privately_and_only_its_owner_removes_it() {
let tmp = tempfile::tempdir().unwrap();
let path = discovery_path(tmp.path());
let ours = Discovery {
address: "100.92.72.95".parse().unwrap(),
port: 1234,
token: "t".into(),
pid: std::process::id(),
version: "0.1.0".into(),
};
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
ours.write(&path).unwrap();
assert_eq!(Discovery::read(&path).unwrap(), ours);
#[cfg(unix)]
@@ -132,10 +118,6 @@ mod tests {
ours.write(&path).unwrap();
Discovery::remove_if_ours(&path);
assert!(!path.exists());
// The file of an IDE that listened on 127.0.0.1 only has no address.
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
}
#[test]
+6 -16
Переглянути файл
@@ -2,15 +2,14 @@
//! the IDE's built-in tools.
//!
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
//! clients too) on `127.0.0.1:<random port>` (or another address of
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
//! `Authorization: Bearer <token>` (random, per run); requests from another
//! `Origin` or `Host` are refused (DNS rebinding).
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
//! names a folder; the server picks the open project containing it.
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
//! `pid`, `version`; readable only by the user) while the server runs.
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
//! `version`; readable only by the user) while the server runs.
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
//! persisted client configurations hold no token.
@@ -24,7 +23,7 @@ mod server;
pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::{McpServer, ServerOptions, local_address};
pub use server::{McpServer, ServerOptions};
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
@@ -86,15 +85,9 @@ pub trait ToolRegistry: Send + Sync + 'static {
) -> Result<String, ToolCallError>;
}
/// The URL of the server at `127.0.0.1:<port>` for `project`.
/// The URL of the server at `port` for `project`.
pub fn endpoint_url(port: u16, project: &Path) -> String {
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
}
/// The URL of the server at `<address>:<port>` for `project`.
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
let at = std::net::SocketAddr::new(address, port);
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
}
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
@@ -140,9 +133,6 @@ mod tests {
fn urls_and_commands_name_the_project() {
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
assert_eq!(
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
+20 -81
Переглянути файл
@@ -9,7 +9,6 @@ use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use std::convert::Infallible;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
@@ -41,8 +40,6 @@ type Body = Either<Full<Bytes>, EventStream>;
struct Shared {
registry: Arc<dyn ToolRegistry>,
token: String,
/// The address listened on (`0.0.0.0` / `::`: every address).
address: IpAddr,
port: u16,
requests: protocol::Requests,
cancel: CancellationToken,
@@ -51,7 +48,6 @@ struct Shared {
/// The running server. Stopping it (or dropping it) closes the port and
/// removes its discovery file.
pub struct McpServer {
address: IpAddr,
port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
@@ -64,10 +60,7 @@ pub struct McpServer {
/// How [`McpServer::start_with`] listens.
#[derive(Clone, Debug, Default)]
pub struct ServerOptions {
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
pub address: Option<IpAddr>,
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
/// `None`: a free port.
pub port: Option<u16>,
@@ -93,20 +86,15 @@ impl McpServer {
runtime: &tokio::runtime::Handle,
options: ServerOptions,
) -> std::io::Result<Self> {
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let bind = |port: u16| {
std::net::TcpListener::bind(SocketAddr::new(address, port))
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
};
let (listener, port_unavailable) = match options.port {
Some(port) => match bind(port) {
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => (listener, None),
Err(e) => {
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
(bind(0)?, Some(format!("port {port}: {e}")))
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
}
},
None => (bind(0)?, None),
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
};
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
@@ -120,7 +108,6 @@ impl McpServer {
};
if let Some(path) = &discovery {
let info = Discovery {
address: local_address(address),
port,
token: token.clone(),
pid: std::process::id(),
@@ -133,19 +120,13 @@ impl McpServer {
let shared = Arc::new(Shared {
registry,
token: token.clone(),
address,
port,
requests: Default::default(),
cancel: cancel.clone(),
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
}
/// The address listened on (`0.0.0.0` / `::`: every address).
pub fn address(&self) -> IpAddr {
self.address
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
}
pub fn port(&self) -> u16 {
@@ -162,24 +143,14 @@ impl McpServer {
&self.token
}
/// The endpoint URL for `project`, at the address a client on this computer connects to.
/// The endpoint URL for `project`.
pub fn url(&self, project: &Path) -> String {
crate::endpoint_url_at(local_address(self.address), self.port, project)
crate::endpoint_url(self.port, project)
}
pub fn stop(self) {}
}
/// The address a client on this computer connects to for a server listening on `address`:
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
pub fn local_address(address: IpAddr) -> IpAddr {
match address {
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
a => a,
}
}
impl Drop for McpServer {
fn drop(&mut self) {
self.cancel.cancel();
@@ -241,7 +212,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
if req.uri().path() != "/mcp" {
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
}
if !local_request(req.headers(), shared.address, shared.port) {
if !local_request(req.headers(), shared.port) {
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
}
if !authorized(req.headers(), &shared.token) {
@@ -372,16 +343,11 @@ impl hyper::body::Body for EventStream {
}
}
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
/// (loopback, the address listened on, or any when listening on every address) or as
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
/// even before the token check.
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
/// No `Origin` other than this server's own, and a loopback `Host`: a web
/// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool {
let ours = |authority: &str| {
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
})
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
};
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
@@ -568,7 +534,7 @@ mod tests {
}
#[test]
fn only_hosts_and_origins_of_this_server_pass() {
fn only_loopback_hosts_and_origins_of_this_server_pass() {
let headers = |pairs: &[(header::HeaderName, &str)]| {
let mut h = HeaderMap::new();
for (k, v) in pairs {
@@ -576,43 +542,16 @@ mod tests {
}
h
};
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
assert!(local_request(&headers(&[]), loopback, 80));
assert!(host("127.0.0.1:80", loopback));
assert!(host("[::1]:80", loopback));
assert!(local_request(&headers(&[]), 80));
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
assert!(local_request(
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
loopback,
80
));
assert!(!host("evil.example:80", loopback));
assert!(!host("127.0.0.1:81", loopback));
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
// Listening on one address: that address too, still never a host name.
let lan: IpAddr = "100.92.72.95".parse().unwrap();
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
// Listening on every address: any IP address, still never a host name.
let every: IpAddr = "0.0.0.0".parse().unwrap();
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
assert!(local_request(
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
every,
80
));
}
#[test]
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
assert_eq!(local_address(ip("::")), ip("::1"));
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
}
#[test]
+2 -3
Переглянути файл
@@ -148,14 +148,13 @@ async fn post(
)
};
let info = Discovery::read(discovery).map_err(|_| not_running())?;
let at = std::net::SocketAddr::new(info.address, info.port);
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
.await
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
tokio::spawn(conn);
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
.header(header::HOST, at.to_string())
.header(header::HOST, format!("127.0.0.1:{}", info.port))
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
.header("mcp-session-id", scope)
.header(header::CONTENT_TYPE, "application/json")
+1 -32
Переглянути файл
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
@@ -393,34 +393,3 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
second.port_unavailable()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
let mut s = setup();
s.server.take().unwrap().stop();
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
// Every address: clients here use loopback; other computers name the server by an address of it.
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
let port = s.server().port();
assert_eq!(s.server().address().to_string(), "0.0.0.0");
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
let request = |host: String| {
format!(
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
bearer(&s),
ping.len()
)
};
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
// Not an address of this computer: the server does not start, and says which address.
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
assert!(error.to_string().contains("192.0.2.1"), "{error}");
}
-5
Переглянути файл
@@ -234,11 +234,6 @@ pub trait HostApi: Send + Sync {
let _ = (path, set, item);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Replace all `(path, icon)` entries of a tree decoration set at once (API 1.8).
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
let _ = (set, items);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Trusted repository history.
fn vcs_log(
&self,
-97
Переглянути файл
@@ -151,67 +151,6 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
}
}
/// Resolves an already-open document for a background edit without following a
/// symbolic link, junction, or other Windows reparse point. Unlike ordinary
/// project reads, background edits may have originated with another machine:
/// accepting a link would let that machine change an unexpected file when the
/// user later saves the editor buffer.
///
/// The input is deliberately project-relative. Existing path components are
/// examined with `symlink_metadata` from the canonical project root; no
/// canonicalization happens after the walk, so a link is never traversed.
pub fn resolve_background_document_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
if path.is_empty() || Path::new(path).is_absolute() {
return Err(format!("{path}: background document paths must be project-relative"));
}
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let components: Vec<_> = Path::new(path).components().collect();
if components.is_empty() || components.iter().any(|part| !matches!(part, std::path::Component::Normal(_))) {
return Err(format!("{path}: background document paths must not leave the project"));
}
let mut current = root.clone();
for (index, part) in components.iter().enumerate() {
let std::path::Component::Normal(name) = part else { unreachable!() };
if background_control_name(name) {
return Err(format!("{path}: background edits may not target VCS or IDE control files"));
}
current.push(name);
let metadata = std::fs::symlink_metadata(&current).map_err(|_| format!("{path}: no such open document"))?;
if is_link_or_reparse(&metadata) {
return Err(format!("{path}: background edits refuse symbolic links"));
}
if index + 1 < components.len() && !metadata.is_dir() {
return Err(format!("{path}: not a directory"));
}
if index + 1 == components.len() && !metadata.is_file() {
return Err(format!("{path}: not a file"));
}
}
Ok(current)
}
/// [`resolve_background_document_in_root`] over a workspace group's roots.
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
first_root_resolving(roots, path, resolve_background_document_in_root)
}
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
#[cfg(windows)]
let reparse = {
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
};
#[cfg(not(windows))]
let reparse = false;
metadata.file_type().is_symlink() || reparse
}
fn background_control_name(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
}
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
/// without requiring it to exist: `Ok(true)` also for a missing path, so
/// callers distinguish "outside" from "absent". Existing symlinks are
@@ -766,25 +705,6 @@ mod tests {
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
}
#[test]
fn background_document_edits_are_relative_and_exclude_control_directories() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
std::fs::write(root.join(".openidea/state"), "state\n").unwrap();
assert!(resolve_background_document_in_root(&root, "src/main.rs").is_ok());
for path in
["/etc/passwd", "../outside", "src/../.git/hooks/pre-commit", ".git/hooks/pre-commit", ".openidea/state"]
{
assert!(resolve_background_document_in_root(&root, path).is_err(), "{path}");
}
}
#[test]
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
for path in [
@@ -820,23 +740,6 @@ mod tests {
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
}
#[cfg(unix)]
#[test]
fn background_document_edits_refuse_final_and_ancestor_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
symlink(root.join("src/main.rs"), root.join("final-link.rs")).unwrap();
symlink(root.join("src"), root.join("linked-src")).unwrap();
assert!(resolve_background_document_in_root(&root, "final-link.rs").unwrap_err().contains("symbolic links"));
assert!(
resolve_background_document_in_root(&root, "linked-src/main.rs").unwrap_err().contains("symbolic links")
);
}
#[cfg(unix)]
#[test]
fn writable_paths_follow_existing_and_dangling_symlinks() {
-3
Переглянути файл
@@ -932,9 +932,6 @@ pub struct FileDecoration {
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
/// `[[contributes.fileIcons]]` id for this file or folder (API 1.8 `set-file-icon`).
#[serde(default)]
pub icon: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+3 -44
Переглянути файл
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
return Ok(Err(wit::Error::Invalid("edits too large".into())));
}
let root = self.table.get(&project)?.root.clone();
let path = match self.resolve_background_document_in_project(&root, &path) {
let path = match self.resolve_in_project(&root, &path) {
Ok(path) => path,
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
};
@@ -936,14 +936,6 @@ impl StoreData {
sandbox::resolve_writable_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
})
}
/// A background document edit is the only plugin operation that changes an
/// editor on behalf of a remote peer. It therefore rejects links and IDE
/// control directories instead of using the normal read resolver.
fn resolve_background_document_in_project(&self, root: &Path, path: &str) -> Result<PathBuf, String> {
sandbox::resolve_background_document_in_root(root, path).or_else(|root_error| {
sandbox::resolve_background_document_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
})
}
/// Whether a `project` resource's path lands inside the resource's root or
/// any member of its workspace group, without requiring it to exist.
fn inside_project(&self, root: &Path, path: &str) -> Result<bool, String> {
@@ -2505,13 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration {
badge: item.badge,
style: item.style,
tooltip: item.tooltip,
note: None,
icon: None,
}),
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
)
.map_err(to_wit))
}
@@ -2529,36 +2515,9 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
fn set_file_icon(&mut self, path: String, set_id: String, icon: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: None, icon: Some(icon) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
fn set_file_icons(&mut self, set_id: String, items: Vec<(String, String)>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
if items.len() > 10_000 {
return Ok(Err(wit::Error::Invalid("at most 10000 file icons per set".into())));
}
let mut resolved = Vec::with_capacity(items.len());
for (path, icon) in items {
match self.decoration_path(&path) {
Ok(real) => resolved.push((real, icon)),
Err(e) => return Ok(Err(e)),
}
}
Ok(self.host.file_icon_decorations(&set_id, resolved).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
+6 -2
Переглянути файл
@@ -39,8 +39,12 @@ impl Plugin for Fixture {
hover: None,
after: Some(after.into()),
};
host::set_decorations(path, "fixture", &[item("typo", "typo message"), item("weak", "weak message")])
.map(|()| None)
host::set_decorations(
path,
"fixture",
&[item("typo", "typo message"), item("weak", "weak message")],
)
.map(|()| None)
}
_ => Err(Error::NotFound(id.into())),
}
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d11f9cb1130f3f3aaa89514bf47ec8ece9b2ca0edf1d82c11f80b262325f16be
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
48ea4414469ebd70a89da8a5df7b7bddc4d32f5c50f1759ee3cea14558073c92
67660f5ebbfdd3967fabcc0b5c41f215d4a38d9092b91eb0166f12bccfdffffa
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0c02303182be04ed3776fb532fd8b72e6057aa0fdb92b7d235000146fef4120e
09c810ce7ebd820ea21525dc9faea99b13e7665b308cb131510e02b86e763c9f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8162a70e848172663463119e6357984a73e54bc71a99b2845621d0733cf9333e
9b3a210440560e05326947c7d549ce691315839daea2931c6a78720f7b0d9e7c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4524cab0ceac631153a945f6b76f7d40efd3c854740ae22c64580381e9532397
57d8b00b5853ca789fe704f21e4394d1524dd73dfd27ea4a0b34f4b0e4f1a5f8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a92f4def072ca4cc805414516ab4f046fc54bcf11394a5413a27ea151c156174
53e355922819b8034c93070323c7eb2b1505d3bf561dd5de6abb930fe514748c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
994fca61bc5a10f0229301406af76c188a9cb2f593db85a403c1ece746e9f7b5
7af929a1adf5d89929d66e78068253ccde9ad5cedee329c2d4db6b7a5e29790e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
48eaf97f57cc08c3854a1effa37ebf29defca4782f76c7de1da6ef9a02c23441
511fab979382c5d64054ba4f5fdfc98d50e252dfd5896265ee589336bf8e7d70
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b896043cf261ff4bb19977f4648a736907fa897b8d4f0804077678a5895d1845
444497128ec5e1e01229a93d2340b49ccc5bc27e67defb2c4fe19683042442a3
-36
Переглянути файл
@@ -5,7 +5,6 @@
pub(crate) mod cmake;
mod dotnet;
mod zig;
use crate::RunConfig;
use ide_core::project_roots::expand_members;
@@ -54,8 +53,6 @@ pub(crate) fn detect_with_profiles(
&mut out,
);
detect_projects_matching(root, ruby_project, detect_ruby, &mut out);
detect_projects(root, &["rebar.config"], detect_rebar, &mut out);
detect_projects(root, &["mix.exs"], detect_mix, &mut out);
for dir in ide_core::project_roots::discover(root, &["CMakeLists.txt"]) {
let start = out.len();
cmake::detect(&dir, if dir == root { profiles } else { None }, &mut out);
@@ -73,7 +70,6 @@ pub(crate) fn detect_with_profiles(
}
}
}
detect_projects(root, &["build.zig"], zig::detect, &mut out);
dotnet::detect(root, &mut out);
detect_projects(root, &["Makefile", "makefile", "GNUmakefile"], detect_make, &mut out);
detect_projects(root, &["justfile", "Justfile", ".justfile"], detect_just, &mut out);
@@ -1330,38 +1326,6 @@ pub(crate) fn just_recipes(src: &str) -> Vec<String> {
// ---------------------------------------------------------------- godot
// ---------------------------------------------------------------- rebar3 / mix
/// rebar3 (Erlang): the shell with the applications started, the EUnit tests and, with
/// `*_SUITE.erl` suites, Common Test. A `rebar3` script in the project wins over the PATH.
fn detect_rebar(root: &Path, out: &mut Vec<RunConfig>) {
let rebar = wrapper(root, "rebar3", "rebar3.cmd", "rebar3");
out.push(config("rebar3:shell", "rebar3 shell", "rebar3", &rebar, &["shell"]));
out.push(config("rebar3:eunit", "rebar3 eunit", "rebar3", &rebar, &["eunit"]).test());
let suites = |dir: &Path| {
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.any(|e| e.file_name().to_string_lossy().ends_with("_SUITE.erl"))
};
let mut apps = subdirs(&root.join("apps")).into_iter().chain(subdirs(&root.join("lib")));
if suites(&root.join("test")) || apps.any(|app| suites(&app.join("test"))) {
out.push(config("rebar3:ct", "rebar3 ct", "rebar3", &rebar, &["ct"]).test());
}
}
/// Mix (Elixir): the application in IEx (`phx.server` for Phoenix) and its ExUnit tests.
fn detect_mix(root: &Path, out: &mut Vec<RunConfig>) {
let phoenix = read(&root.join("mix.exs")).is_some_and(|src| src.contains("{:phoenix,"));
if phoenix {
out.push(config("mix:phx.server", "iex -S mix phx.server", "mix", "iex", &["-S", "mix", "phx.server"]));
} else {
out.push(config("mix:iex", "iex -S mix", "mix", "iex", &["-S", "mix"]));
}
out.push(config("mix:test", "mix test", "mix", "mix", &["test"]).test());
}
/// Godot projects (`project.godot`) at the root or one level down: "Godot Project"
/// runs the main scene (`godot --path .`), debuggable through the editor's adapter.
fn detect_godot(root: &Path, out: &mut Vec<RunConfig>) {
-232
Переглянути файл
@@ -1,232 +0,0 @@
//! Zig projects (`build.zig`): the build's steps (`zig build <step>`) and, for each executable it
//! declares, the installed binary (`zig-out/bin/<name>`), built first and debuggable with CodeLLDB.
//! Read from the build script's source: detecting configurations never runs the project's build code.
use super::{ConfigExt, config, read};
use crate::{BeforeLaunchTask, RunConfig};
use std::path::Path;
pub(super) const BUILD_ID: &str = "zig:build";
pub(super) fn detect(dir: &Path, out: &mut Vec<RunConfig>) {
let Some(src) = read(&dir.join("build.zig")) else { return };
let src = code_only(&src);
out.push(config(BUILD_ID, "zig build", "zig", "zig", &["build"]));
for step in steps(&src) {
// `install` is what plain `zig build` runs.
if step == "install" || step == "uninstall" {
continue;
}
let mut c = config(format!("zig:{step}"), format!("zig build {step}"), "zig", "zig", &["build", &step]);
if step == "test" || step.starts_with("test-") || step.starts_with("test_") {
c = c.test();
}
out.push(c);
}
for exe in executables(&src) {
let binary = if cfg!(windows) {
format!("zig-out\\bin\\{}.exe", exe.name)
} else {
format!("./zig-out/bin/{}", exe.name)
};
let mut c = config(format!("zig:exe:{}", exe.name), exe.name.clone(), "zig-application", &binary, &[])
.debug("codelldb");
c.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: BUILD_ID.into() });
if let Some(source) = exe.source {
c.extras.params.insert("source".into(), source);
}
out.push(c);
}
}
/// `src` with comments blanked out (Zig has only `//` comments) and string contents kept.
fn code_only(src: &str) -> String {
let mut out = String::with_capacity(src.len());
for line in src.lines() {
let mut in_string = false;
let mut escaped = false;
let mut end = line.len();
let bytes = line.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if in_string {
match b {
_ if escaped => escaped = false,
b'\\' => escaped = true,
b'"' => in_string = false,
_ => {}
}
} else if b == b'"' {
in_string = true;
} else if b == b'/' && bytes.get(i + 1) == Some(&b'/') {
end = i;
break;
}
}
out.push_str(&line[..end]);
out.push('\n');
}
out
}
/// The string literal starting at `s` (after optional whitespace), unescaped only for `\"`.
fn string_at(s: &str) -> Option<String> {
let rest = s.trim_start().strip_prefix('"')?;
let mut value = String::new();
let mut chars = rest.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(value),
'\\' => value.push(chars.next()?),
c => value.push(c),
}
}
None
}
/// Step names from `<build>.step("name", "description")`, in order and without duplicates.
fn steps(src: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (i, _) in src.match_indices(".step(") {
let preceded = src[..i].chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_');
if let Some(name) = string_at(&src[i + ".step(".len()..])
&& preceded
&& !name.is_empty()
&& !out.contains(&name)
{
out.push(name);
}
}
out
}
struct Executable {
name: String,
/// Root source file, relative to the build script's folder.
source: Option<String>,
}
/// `addExecutable(.{ .name = "…", … .root_source_file = <b>.path("…") … })`: the name at the
/// options' top level (an `imports` entry has its own `.name`) and the first root source file.
fn executables(src: &str) -> Vec<Executable> {
let mut out: Vec<Executable> = Vec::new();
for (start, _) in src.match_indices("addExecutable(") {
let body = &src[start + "addExecutable(".len()..];
let (mut depth, mut in_string, mut escaped) = (1usize, false, false);
let mut name = None;
let mut source = None;
for (i, c) in body.char_indices() {
if in_string {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'(' | '{' | '[' => depth += 1,
')' | '}' | ']' => {
depth -= 1;
if depth == 0 {
break;
}
}
'.' => {
let field = &body[i + 1..];
let value =
|key: &str| field.strip_prefix(key)?.trim_start().strip_prefix('=').map(str::trim_start);
if depth == 2 && name.is_none() {
name = value("name").and_then(string_at);
}
if source.is_none()
&& let Some(v) = value("root_source_file")
&& let Some(call) = v.find(".path(")
&& v[..call].chars().all(|c| c.is_alphanumeric() || c == '_')
{
source = string_at(&v[call + ".path(".len()..]);
}
}
_ => {}
}
}
if let Some(name) = name
&& !name.is_empty()
&& !out.iter().any(|e| e.name == name)
{
out.push(Executable { name, source });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const INIT: &str = r#"const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const mod = b.addModule("app", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
});
// const old = b.addExecutable(.{ .name = "commented-out" });
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.imports = &.{
.{ .name = "app", .module = mod },
},
}),
});
b.installArtifact(exe);
const tool = b.addExecutable(.{ .name = "gen-docs", .root_module = b.createModule(.{
.root_source_file = b.path("tools/gen_docs.zig"),
}) });
_ = tool;
const run_step = b.step("run", "Run the app");
const test_step = b.step("test", "Run tests");
const docs = b.step("docs", "Generate docs // not a comment");
_ = .{ run_step, test_step, docs };
}
"#;
#[test]
fn steps_and_executables_come_from_the_build_script() {
let src = code_only(INIT);
assert_eq!(steps(&src), ["run", "test", "docs"]);
let exes = executables(&src);
assert_eq!(
exes.iter().map(|e| (e.name.as_str(), e.source.as_deref())).collect::<Vec<_>>(),
[("app", Some("src/main.zig")), ("gen-docs", Some("tools/gen_docs.zig"))]
);
}
#[test]
fn a_zig_project_gets_build_steps_and_debuggable_executables() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.zig"), INIT).unwrap();
let mut out = Vec::new();
detect(dir.path(), &mut out);
let ids: Vec<_> = out.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, ["zig:build", "zig:run", "zig:test", "zig:docs", "zig:exe:app", "zig:exe:gen-docs"]);
let test = out.iter().find(|c| c.id == "zig:test").unwrap();
assert!(test.is_test);
assert_eq!(
(test.command.as_str(), test.args.as_slice()),
("zig", ["build".to_owned(), "test".to_owned()].as_slice())
);
let app = out.iter().find(|c| c.id == "zig:exe:app").unwrap();
assert_eq!(app.kind, "zig-application");
assert!(app.command.ends_with(if cfg!(windows) { "app.exe" } else { "zig-out/bin/app" }));
assert_eq!(app.debug_adapter.as_deref(), Some("codelldb"));
assert_eq!(app.extras.before_launch[0].kind, "run");
assert_eq!(app.extras.before_launch[0].target, BUILD_ID);
assert_eq!(app.extras.params.get("source").map(String::as_str), Some("src/main.zig"));
}
}
+4 -14
Переглянути файл
@@ -26,10 +26,8 @@ pub struct TestCaseReport {
pub duration_ms: Option<f64>,
/// Failure / error / skip message.
pub message: Option<String>,
/// Failure / error stack trace.
/// Failure stack trace and captured output.
pub output: Option<String>,
/// What the test printed (`<system-out>`, `<system-err>`).
pub stdout: Option<String>,
}
fn decode(s: &str) -> String {
@@ -126,13 +124,11 @@ pub fn parse_report(xml: &str) -> Vec<TestCaseReport> {
if let Some(name) = tag.strip_prefix('/') {
match name.trim() {
"testcase" => out.extend(current.take()),
name @ ("failure" | "error" | "rerunFailure" | "rerunError" | "skipped" | "system-out"
| "system-err") => {
"failure" | "error" | "rerunFailure" | "rerunError" | "skipped" | "system-out" | "system-err" => {
if let (Some(t), Some(c)) = (text.take(), current.as_mut()) {
let t = t.trim_matches('\n');
if !t.trim().is_empty() {
let field = if name.starts_with("system-") { &mut c.stdout } else { &mut c.output };
let o = field.get_or_insert_with(String::new);
let o = c.output.get_or_insert_with(String::new);
if !o.is_empty() {
o.push('\n');
}
@@ -164,7 +160,6 @@ pub fn parse_report(xml: &str) -> Vec<TestCaseReport> {
duration_ms,
message: None,
output: None,
stdout: None,
};
if self_closing {
out.push(case);
@@ -243,8 +238,6 @@ mod tests {
<failure message="expected: &lt;1&gt; but was: &lt;2&gt;" type="org.opentest4j.AssertionFailedError"><![CDATA[org.opentest4j.AssertionFailedError: expected: <1> but was: <2>
at com.example.demo.todo.TodoServiceTests.fails(TodoServiceTests.java:30)
]]></failure>
<system-out><![CDATA[printed before failing
]]></system-out>
</testcase>
<testcase name="later" classname="com.example.demo.todo.TodoServiceTests" time="0">
<skipped message="not yet"/>
@@ -261,13 +254,10 @@ mod tests {
assert_eq!(cases[0].status, TestCaseStatus::Passed);
assert_eq!(cases[0].duration_ms, Some(31.0));
assert_eq!(cases[1].name, "deleteReturnsFalseForUnknownId");
assert_eq!(cases[1].stdout.as_deref(), Some("hello <world>"));
assert_eq!(cases[1].output, None);
assert_eq!(cases[1].output.as_deref(), Some("hello <world>"));
assert_eq!(cases[2].status, TestCaseStatus::Failed);
assert_eq!(cases[2].message.as_deref(), Some("expected: <1> but was: <2>"));
assert!(cases[2].output.as_deref().unwrap().contains("TodoServiceTests.java:30"));
assert!(!cases[2].output.as_deref().unwrap().contains("printed"));
assert_eq!(cases[2].stdout.as_deref(), Some("printed before failing"));
assert_eq!(cases[2].duration_ms, Some(1_234_500.0));
assert_eq!(cases[3].status, TestCaseStatus::Skipped);
assert_eq!(cases[3].message.as_deref(), Some("not yet"));
-33
Переглянути файл
@@ -1173,36 +1173,3 @@ fn a_django_project_in_src_uses_the_environment_next_to_it() {
assert!(c.iter().any(|c| c.id == "pytest"));
assert!(!c.iter().any(|c| c.id.starts_with("pytest:")), "{:?}", c.iter().map(|c| &c.id).collect::<Vec<_>>());
}
#[test]
fn rebar3_projects() {
let dir = project(&[("rebar.config", "{erl_opts, [debug_info]}.\n"), ("src/app.erl", "-module(app).\n")]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["rebar3:shell", "rebar3:eunit"]);
assert_eq!(cmdline(&c[0]), "rebar3 shell");
assert!(c[1].is_test);
// Common Test suites in an umbrella app; the project's own rebar3 script.
let dir = project(&[
("rebar.config", ""),
("rebar3", "#!/usr/bin/env escript\n"),
("apps/core/test/core_SUITE.erl", "-module(core_SUITE).\n"),
]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["rebar3:shell", "rebar3:eunit", "rebar3:ct"]);
if !cfg!(windows) {
assert_eq!(cmdline(find(&c, "rebar3:ct")), "./rebar3 ct");
}
}
#[test]
fn mix_projects() {
let dir = project(&[("mix.exs", "defmodule App.MixProject do\n defp deps, do: []\nend\n")]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["mix:iex", "mix:test"]);
assert_eq!(cmdline(&c[0]), "iex -S mix");
assert!(c[1].is_test);
let dir = project(&[("mix.exs", "defp deps do\n [{:phoenix, \"~> 1.7\"}]\nend\n")]);
assert_eq!(cmdline(&detect(dir.path())[0]), "iex -S mix phx.server");
}
+2 -27
Переглянути файл
@@ -3,7 +3,7 @@
use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path, which};
use crate::npm::{has_ts_api, native_typescript, own_typescript};
use crate::npm::{has_ts_api, own_typescript};
use crate::{angular, astro, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
@@ -722,16 +722,6 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
}
}
/// TypeScript 7's native executable when the selected TypeScript is one (see
/// [`native_typescript`]); `None` for the bundled one and TypeScript 6 and older.
pub fn native_tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
match s.languages.typescript.version.trim() {
"" | "bundled" => None,
"workspace" => native_typescript(&root.join("node_modules/typescript")),
custom => native_typescript(&setting_path(root, custom)?),
}
}
const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "pylsp", "jedi-language-server", "ruff"];
pub(crate) fn merge(base: &mut Value, layer: Value) {
@@ -967,15 +957,10 @@ pub fn resolve_run_command(config: &mut RunConfig, root: &Path, s: &Settings) {
}
pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) {
let mut path_dirs: Vec<PathBuf> = Vec::new();
if let Some(custom) = tool_command(root, s, &cfg.command) {
// An executable chosen from a Node.js installation (`~/.nvm/versions/node/v26/bin/tsc`,
// its `npx`) runs on that Node: its `#!/usr/bin/env node` finds the `node` beside it.
if let Some(bin) = custom.parent().filter(|bin| bin.join(exe("node")).is_file()) {
path_dirs.push(bin.to_path_buf());
}
cfg.command = custom.to_string_lossy().into_owned();
}
let mut path_dirs: Vec<PathBuf> = Vec::new();
let name = cfg.name.clone();
let name = name.as_str();
@@ -1150,16 +1135,6 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
_ => {}
}
}
// TypeScript 7 serves LSP itself: it replaces the tsserver-based server.
if matches!(name, "vtsls" | "typescript-language-server")
&& let Some(native) = native_tsdk(server_root, s).or_else(|| native_tsdk(root, s))
{
cfg.command = native.to_string_lossy().into_owned();
cfg.args = vec!["--lsp".into(), "--stdio".into()];
cfg.initialization_options = None;
cfg.tsserver_project = false;
return;
}
let vue_command = s.tool_path(vue::SERVER).and_then(|p| setting_path(root, p));
let vue_command = vue_command.as_deref().map_or(vue::SERVER.into(), Path::to_string_lossy);
if name == "vtsls"
+2 -14
Переглянути файл
@@ -749,26 +749,14 @@ pub(crate) fn workspace_tsdk(root: &Path) -> Option<PathBuf> {
fn typescript(root: Option<&Path>) -> Vec<Sdk> {
let mut out = Vec::new();
// TypeScript 7 has no `tsserver.js`; its package directory stands for it (`native_tsdk`).
let native = || {
let package = root?.join("node_modules/typescript");
crate::npm::native_typescript(&package).map(|_| package.join("lib"))
};
if let Some(lib) = root.and_then(workspace_tsdk).or_else(native) {
if let Some(lib) = root.and_then(workspace_tsdk) {
let version = std::fs::read_to_string(lib.parent().unwrap_or(&lib).join("package.json"))
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v.get("version").and_then(|v| v.as_str()).map(str::to_owned));
out.push(Sdk {
path: lib.clone().into(),
name: format!(
"TypeScript {}{}",
version.clone().unwrap_or_default(),
if crate::npm::has_ts_api(&lib) { "" } else { " (native)" }
)
.replace(" ", " ")
.trim()
.to_owned(),
name: format!("TypeScript {}", version.clone().unwrap_or_default()).trim().to_owned(),
version,
source: "workspace".into(),
home: lib.parent().map(Into::into),
-35
Переглянути файл
@@ -10,41 +10,6 @@ pub(crate) fn has_ts_api(lib: &Path) -> bool {
lib.join("typescript.js").is_file()
}
/// The native executable of TypeScript 7 (`typescript` 7+, whose `tsc` is native, or
/// `@typescript/native-preview`'s `tsgo`), from its package directory or its `lib`.
/// These packages have no JavaScript API (`lib/typescript.js`); their platform package
/// (`@typescript/typescript-darwin-arm64`) holds the executable, which serves LSP itself
/// (`--lsp --stdio`).
pub(crate) fn native_typescript(dir: &Path) -> Option<PathBuf> {
let package = if dir.join("package.json").is_file() { dir } else { dir.parent()? };
if has_ts_api(&package.join("lib")) {
return None;
}
let manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(package.join("package.json")).ok()?).ok()?;
let (base, bin) = match manifest.get("name")?.as_str()? {
"typescript" => ("typescript", "tsc"),
"@typescript/native-preview" => ("native-preview", "tsgo"),
_ => return None,
};
let os = match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
os => os,
};
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x64",
arch => arch,
};
let platform = format!("{base}-{os}-{arch}");
// Next to the package (`node_modules/@typescript/…`) or nested in it.
package
.ancestors()
.map(|a| a.join("node_modules/@typescript").join(&platform).join("lib").join(crate::exe(bin)))
.find(|exe| exe.is_file())
}
/// The usable TypeScript library Node resolves from an installed server package.
pub(crate) fn own_typescript(package: &Path) -> Option<PathBuf> {
package.ancestors().map(|a| a.join("node_modules/typescript/lib")).find(|l| has_ts_api(l))
-79
Переглянути файл
@@ -1325,85 +1325,6 @@ fn custom_tool_path_replaces_the_command() {
assert_eq!(cfg.command, "zls");
}
/// A server picked from a Node.js installation (nvm's `bin/tsc`, `bin/npx`) is a
/// `#!/usr/bin/env node` script: it runs on that installation's Node, ahead of the
/// project's Node.js interpreter.
#[test]
fn a_server_chosen_from_a_node_installation_runs_on_that_node() {
let tmp = tempfile::tempdir().unwrap();
let v26 = tmp.path().join("nvm/versions/node/v26.4.0/bin");
let v20 = tmp.path().join("node20/bin");
for dir in [&v26, &v20] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(exe("node")), "").unwrap();
}
std::fs::write(v26.join("tsc"), "#!/usr/bin/env node\n").unwrap();
let mut s = Settings::default();
s.languages.node.interpreter = v20.join(exe("node")).to_string_lossy().into_owned();
s.tools.insert(
"typescript-language-server".into(),
ide_core::settings::ToolSettings { path: v26.join("tsc").to_string_lossy().into_owned() },
);
let mut cfg = server("typescript-language-server");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let path: Vec<std::path::PathBuf> = std::env::split_paths(&cfg.env["PATH"]).collect();
assert_eq!(path[..2], [v26.clone(), v20.clone()]);
// Elsewhere, the project's Node.js interpreter comes first.
s.tools.insert(
"typescript-language-server".into(),
ide_core::settings::ToolSettings { path: tmp.path().join("tools/tsc").to_string_lossy().into_owned() },
);
let mut cfg = server("typescript-language-server");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(std::env::split_paths(&cfg.env["PATH"]).next(), Some(v20));
}
/// TypeScript 7 (`typescript` 7+, `@typescript/native-preview`) has no tsserver: choosing it
/// runs its native executable's own language server instead of vtsls / typescript-language-server.
#[test]
fn typescript_7_runs_its_own_language_server() {
let tmp = tempfile::tempdir().unwrap();
let modules = tmp.path().join("node_modules");
let os = match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
os => os,
};
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x64",
arch => arch,
};
std::fs::create_dir_all(modules.join("typescript/lib")).unwrap();
std::fs::write(modules.join("typescript/package.json"), r#"{"name":"typescript","version":"7.0.2"}"#).unwrap();
let native = modules.join(format!("@typescript/typescript-{os}-{arch}/lib")).join(exe("tsc"));
std::fs::create_dir_all(native.parent().unwrap()).unwrap();
std::fs::write(&native, "").unwrap();
let mut s = Settings::default();
s.languages.typescript.version = "workspace".into();
for name in ["vtsls", "typescript-language-server"] {
let mut cfg = server(name);
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), native, "{name}");
assert_eq!(cfg.args, ["--lsp", "--stdio"]);
assert!(cfg.initialization_options.is_none());
}
// Picked as a directory (its `lib`, as for older versions), too.
s.languages.typescript.version = modules.join("typescript/lib").to_string_lossy().into_owned();
let mut cfg = server("vtsls");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), native);
// TypeScript 6 keeps the tsserver-based server, pointed at it.
std::fs::write(modules.join("typescript/lib/typescript.js"), "").unwrap();
std::fs::write(modules.join("typescript/lib/tsserver.js"), "").unwrap();
s.languages.typescript.version = "workspace".into();
let mut cfg = server("vtsls");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(cfg.command, "vtsls");
}
#[test]
fn the_godot_setting_applies_to_server_debugger_and_run_configurations() {
let tmp = tempfile::tempdir().unwrap();
+1 -23
Переглянути файл
@@ -1,8 +1,7 @@
//! An in-process SSH server for tests (feature `testing`): password `dev` /
//! `secret`, commands answered by a closure, SFTP served from a local
//! folder (remote `/x` is `<root>/x`), and a shell that echoes its input
//! (`exit` ends it with status 0). `direct-tcpip` channels are forwarded to local TCP
//! ports, and refused when nothing listens there.
//! (`exit` ends it with status 0).
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
use russh::{Channel, ChannelId};
@@ -72,27 +71,6 @@ impl server::Handler for Conn {
Ok(())
}
async fn channel_open_direct_tcpip(
&mut self,
channel: Channel<Msg>,
host: &str,
port: u32,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
let Ok(mut upstream) = tokio::net::TcpStream::connect((host, port as u16)).await else {
return Ok(());
};
reply.accept().await;
tokio::spawn(async move {
let mut stream = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
});
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
-82
Переглянути файл
@@ -104,17 +104,6 @@ pub enum InstallEvent {
Progress { fraction: Option<f64>, message: String },
}
/// A newer version of a tool the IDE installed, as its install source offers it now.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ToolUpdate {
pub name: String,
/// The installed version.
pub current: String,
/// The version installing the tool again would get.
pub latest: String,
}
#[derive(Serialize, Deserialize)]
struct Manifest {
version: Option<String>,
@@ -1448,77 +1437,6 @@ impl Tools {
self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) })
}
/// Newer versions of the tools the IDE installed into its tools folder, for the install
/// sources that can be asked: release downloads (GitHub, Open VSX, Maven Central, …) and
/// npm packages. A tool whose lookup fails (offline, rate limited) is left out.
pub fn updates(&self, cancel: &CancellationToken) -> IdeResult<Vec<ToolUpdate>> {
let installed: Vec<(&ToolSpec, Method, String)> = TOOLS
.iter()
.filter_map(|spec| {
let method = self.install_method(spec);
Some((spec, method, self.installed_version(spec, method)?))
})
.collect();
let updates = std::thread::scope(|scope| {
let lookups: Vec<_> = installed
.iter()
.map(|(spec, method, current)| {
scope.spawn(move || match self.for_method(*method).latest_version(*method, cancel) {
Ok(Some(latest)) if latest != *current => {
Some(ToolUpdate { name: spec.name.into(), current: current.clone(), latest })
}
Ok(_) => None,
Err(e) => {
tracing::info!("update check for {}: {}", spec.name, e.message);
None
}
})
})
.collect();
lookups.into_iter().filter_map(|lookup| lookup.join().ok().flatten()).collect()
});
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(updates)
}
/// The version of `spec` in the IDE's tools folder, when `method` (how it was installed)
/// can tell the latest one.
fn installed_version(&self, spec: &ToolSpec, method: Method) -> Option<String> {
match method {
Method::Release { .. } => {
managed_bin_dir(&self.root, spec, self.platform).and_then(|dir| self.exe_in(&dir, spec.commands[0]))?;
self.manifest(spec.name)?.version
}
// A pinned package (`typescript@6`) stays on its major version.
Method::Npm { packages } if npm_name(packages[0]) == packages[0] => self.npm_package_version(packages[0]),
_ => None,
}
}
/// The version installing with `method` would get now.
fn latest_version(&self, method: Method, cancel: &CancellationToken) -> IdeResult<Option<String>> {
match method {
Method::Release { source, assets, .. } => {
let http = Http::new(&self.system_path)?;
Ok(Some(self.resolve_release(&http, source, assets, cancel)?.version))
}
Method::Npm { packages } => {
let (manager, _) = self.npm_manager()?;
let mut c = self.command(manager);
// The registry the user's npm configuration names (a mirror, a proxy). A cold npm
// on a slow registry takes several seconds per package, more when run side by side.
c.args(["view", packages[0], "version"]).current_dir(&self.root);
match run_quiet(c, Duration::from_secs(60)) {
Some((true, out)) => Ok(out.lines().map(str::trim).rfind(|l| !l.is_empty()).map(String::from)),
_ => Err(IdeError::unavailable(format!("cannot look up the latest {}", packages[0]))),
}
}
_ => Ok(None),
}
}
/// Removes a tool the IDE installed.
pub fn uninstall(&self, name: &str, langs: &[LanguageConfig]) -> IdeResult<ToolInfo> {
match registry::find(name) {
-23
Переглянути файл
@@ -935,26 +935,3 @@ fn a_cancelled_install_leaves_the_previous_one_in_place() {
assert!(dest.join("new").exists() && !dest.join("old").exists());
assert!(aside.join("old").exists(), "the previous install is moved aside");
}
/// An npm tool the IDE installed is offered the version the user's npm registry has now.
#[cfg(unix)]
#[test]
fn npm_tools_report_a_newer_registry_version() {
let env = Env::new();
let sdk = env._tmp.path().join("node-sdk/bin");
let modules = env.root.join("node_modules");
env.script(&modules.join(".bin"), "bash-language-server", "echo 5.4.0");
fs::create_dir_all(modules.join("bash-language-server")).unwrap();
fs::write(modules.join("bash-language-server/package.json"), r#"{"version":"5.4.0"}"#).unwrap();
env.script(&sdk, "node", "exit 0");
let tools = |latest: &str| {
env.script(&sdk, "npm", &format!(r#"[ "$1 $2 $3" = "view bash-language-server version" ] && echo {latest}"#));
env.tools().with_runtime(BTreeMap::from([("node".into(), sdk.join("node"))]), BTreeMap::new())
};
let cancel = CancellationToken::new();
assert_eq!(
tools("5.6.1").updates(&cancel).unwrap(),
vec![ToolUpdate { name: "bash-language-server".into(), current: "5.4.0".into(), latest: "5.6.1".into() }]
);
assert_eq!(tools("5.4.0").updates(&cancel).unwrap(), vec![]);
}
-12
Переглянути файл
@@ -41,14 +41,6 @@ pub trait Backend: Send + Sync {
/// as `unstaged`.
fn status(&self) -> IdeResult<Vec<FileStatus>>;
/// Working-area changes for `paths` only (absolute; those outside the root
/// are ignored), which is what a watcher batch needs. The default is the
/// full status: always correct, just not proportional to the batch.
fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
let _ = paths;
self.status()
}
/// Text of `path` at `rev`; `None` when absent there.
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>>;
@@ -197,10 +189,6 @@ impl Backend for Repo {
Repo::status(self)
}
fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
Repo::status_paths(self, paths)
}
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>> {
Repo::file_at(self, path, rev, encoding)
}
-18
Переглянути файл
@@ -764,24 +764,6 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
status::status(self)
}
/// Status of `paths` only (absolute; those outside the repository are ignored), for the
/// dirty-scope refresh after a watcher batch. An empty list is the full status.
pub fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
// Restricted Mode starts no program of the repository, and the scoped read is `git status`
// with a pathspec: answer with the full gitoxide status instead.
if !self.execution_allowed.load(std::sync::atomic::Ordering::Acquire) {
return status::status(self);
}
if paths.is_empty() {
return status::status_paths(self, &[]);
}
let rel: Vec<String> = paths.iter().filter_map(|p| self.given_rel_path(p).ok()).collect();
if rel.is_empty() {
return Ok(Vec::new());
}
status::status_paths(self, &rel)
}
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
refs::branch_info(self)
Loaded 100 of 261 files, more files were not shown because too many files have changed in this diff. Show more