mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 17:37:06 -10:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9362247d0 | ||
|
|
8ab9c688ac | ||
|
|
ba0b3d9c7d | ||
|
|
96f8ee27dd | ||
|
|
5529db667c | ||
|
|
9c55c28d5b | ||
|
|
009773c0d4 | ||
|
|
ba0f4329ea | ||
|
|
4d88d44616 | ||
|
|
5e5d804b89 | ||
|
|
83b9744a36 | ||
|
|
4ab8959bac | ||
|
|
d715643ff2 | ||
|
|
a5214cae38 | ||
|
|
54dd3a167f | ||
|
|
0938a507da | ||
|
|
a05a40e0a3 | ||
|
|
3b978721e3 | ||
|
|
9793857024 | ||
|
|
8fb3b7b084 | ||
|
|
22fe1702be | ||
|
|
ad434af78c | ||
|
|
6d103df5cf | ||
|
|
e2d089a0c4 | ||
|
|
47fa0e6f79 | ||
|
|
921f1f2b2d | ||
|
|
88f73630a6 | ||
|
|
504ffac10a | ||
|
|
5ee558d1a7 | ||
|
|
c09fb7191a | ||
|
|
7ee80ed736 | ||
|
|
353768a15f | ||
|
|
5e969f0bad | ||
|
|
6f60f55a1d | ||
|
|
8e5ac1dc01 | ||
|
|
4fcf4555a1 | ||
|
|
40494f2920 | ||
|
|
c016007a2d | ||
|
|
69ce1ab13d | ||
|
|
fcc8583f36 | ||
|
|
b368f21eb6 | ||
|
|
929866bbc5 | ||
|
|
3c781296d6 | ||
|
|
4aed449dc0 | ||
|
|
55431f3965 | ||
|
|
d4b14a36c7 | ||
|
|
d2c565b19d | ||
|
|
01a60ef164 | ||
|
|
b0b768d25e | ||
|
|
3240bfc423 | ||
|
|
15182507cf | ||
|
|
38bd8fbd16 | ||
|
|
2859ec9106 | ||
|
|
a4b62ad27f |
No files matched your search
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
|
||||
RUFF=0.17.3
|
||||
GOPLS=v0.24.0
|
||||
JDTLS=1.63.0-202610191322
|
||||
KOTLIN_LSP=263.7084.0
|
||||
KOTLIN_LSP=271.2085.0
|
||||
GRADLE=8.14.3
|
||||
INTELEPHENSE=1.18.5
|
||||
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
|
||||
|
||||
@@ -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
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
|
||||
@@ -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 127.0.0.1 while the IDE runs.
|
||||
/// Serve the tools on [`Self::address`] 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,6 +300,9 @@ 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,
|
||||
@@ -312,6 +315,8 @@ 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 {
|
||||
@@ -320,6 +325,7 @@ 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,
|
||||
|
||||
+46
-12
@@ -324,6 +324,17 @@ 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)),
|
||||
@@ -655,9 +666,7 @@ impl DbManager {
|
||||
ssh_secret: Option<String>,
|
||||
shared: bool,
|
||||
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
|
||||
// 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 {
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
|
||||
return Ok((source.clone(), None));
|
||||
};
|
||||
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
|
||||
@@ -668,8 +677,25 @@ 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();
|
||||
let session = if config.is_empty() {
|
||||
Ok(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,
|
||||
@@ -688,14 +714,22 @@ impl DbManager {
|
||||
self.ssh.set_secret(config, Some(s), save);
|
||||
}
|
||||
self.ssh.session(config).await?
|
||||
};
|
||||
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)))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
/// Connects with `source` as given (not necessarily saved): Test Connection.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
@@ -42,6 +42,10 @@ 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
|
||||
@@ -212,6 +216,17 @@ 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();
|
||||
|
||||
@@ -74,9 +74,15 @@ 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 items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
|
||||
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if items.is_empty() {
|
||||
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
|
||||
return if covered {
|
||||
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
|
||||
} else {
|
||||
result
|
||||
};
|
||||
}
|
||||
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
|
||||
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
|
||||
@@ -309,9 +315,13 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
|
||||
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
|
||||
// editor to tell.
|
||||
if target == doc
|
||||
&& kind == GotoKind::Definition
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
&& !on_declaration_name(host, window, doc, pos).await
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
// A notebook cell is a language-server document only: no plugin navigates in it.
|
||||
@@ -352,6 +362,30 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
|
||||
definition_targets(result, found, in_string)
|
||||
}
|
||||
|
||||
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
|
||||
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
|
||||
let Ok(project) = host.project(window) else { return false };
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(false);
|
||||
}
|
||||
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
|
||||
Ok(is_declaration_name(&at))
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
|
||||
at.node.field.as_deref() == Some("name")
|
||||
&& at.ancestors.first().is_some_and(|parent| {
|
||||
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
|
||||
})
|
||||
}
|
||||
|
||||
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
|
||||
let Ok(project) = host.project(window) else { return false };
|
||||
blocking(move || {
|
||||
@@ -2136,6 +2170,24 @@ mod tests {
|
||||
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaration_names_are_told_apart_from_uses() {
|
||||
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
|
||||
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
|
||||
let java = ide_core::LanguageId::new("java");
|
||||
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
|
||||
let snap = ide_syntax::Snapshot::text(&java, text);
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let at = |line, col| {
|
||||
let pos = Pos::new(line, col);
|
||||
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
|
||||
};
|
||||
assert!(at(1, 10), "the mapper method's name");
|
||||
assert!(at(0, 12), "the interface's name");
|
||||
assert!(!at(3, 45), "a call of the method");
|
||||
assert!(!at(1, 4), "its return type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_inside_string_literals_are_told_apart() {
|
||||
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
|
||||
|
||||
@@ -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, Tools};
|
||||
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
@@ -89,6 +89,16 @@ 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)?;
|
||||
@@ -185,6 +195,7 @@ 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
|
||||
|
||||
@@ -22,6 +22,27 @@ 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::*;
|
||||
|
||||
|
||||
@@ -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("unset_upstream")?)
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
|
||||
|
||||
@@ -1642,6 +1642,9 @@ 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,
|
||||
@@ -4124,6 +4127,9 @@ 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()));
|
||||
@@ -4132,24 +4138,14 @@ 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
|
||||
.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()));
|
||||
let root = self.decoration_root(path);
|
||||
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() || decoration_style(&f.style)),
|
||||
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -4177,6 +4173,60 @@ 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!(
|
||||
@@ -4330,7 +4380,8 @@ mod workbench_tests {
|
||||
badge: Some("abc".into()),
|
||||
style: "info".into(),
|
||||
tooltip: None,
|
||||
note: None
|
||||
note: None,
|
||||
icon: None,
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
@@ -4340,6 +4391,7 @@ 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| {
|
||||
@@ -4353,6 +4405,18 @@ 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());
|
||||
}
|
||||
|
||||
@@ -396,15 +396,37 @@ 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.feature_documents(doc, Some(at))
|
||||
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))
|
||||
.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)
|
||||
@@ -419,7 +441,8 @@ impl PluginHost {
|
||||
Some((p, i))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(covered, items)
|
||||
}
|
||||
|
||||
pub fn provide_code_actions(
|
||||
|
||||
@@ -743,6 +743,7 @@ 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)]
|
||||
@@ -754,13 +755,19 @@ 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`,
|
||||
/// whatever icon theme is in use (the user's own file type icons still win).
|
||||
/// `[[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).
|
||||
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);
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
if d.patterns.is_empty() {
|
||||
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
|
||||
} else {
|
||||
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)] {
|
||||
@@ -769,8 +776,16 @@ 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 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);
|
||||
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);
|
||||
out.files = files;
|
||||
out.assets = assets;
|
||||
Ok(out)
|
||||
|
||||
@@ -270,7 +270,8 @@ 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", |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 });
|
||||
ops::register_all(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
@@ -475,6 +475,7 @@ 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"
|
||||
|
||||
@@ -99,6 +99,21 @@ 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,
|
||||
|
||||
@@ -2554,7 +2554,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
f.install(&official("git-toolbox"));
|
||||
// Focusing an editor wakes it to show the branch (nothing fetched yet).
|
||||
let now = Instant::now();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
|
||||
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
|
||||
f.host.flush_events_at(now + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let item = |host: &PluginHost| {
|
||||
@@ -2992,13 +2993,18 @@ fn official_csv_editor_edits_the_shared_document() {
|
||||
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
|
||||
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
|
||||
|
||||
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
|
||||
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
|
||||
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
|
||||
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
|
||||
|
||||
// Settings: without a header row every record is a row.
|
||||
let mut values = serde_json::Map::new();
|
||||
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
|
||||
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
|
||||
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
|
||||
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
|
||||
|
||||
// Undeclared views are refused before any code runs.
|
||||
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
|
||||
@@ -3754,6 +3760,43 @@ 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());
|
||||
|
||||
@@ -367,6 +367,7 @@ 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(
|
||||
@@ -426,42 +427,70 @@ 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!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
|
||||
.await
|
||||
.unwrap();
|
||||
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);
|
||||
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"));
|
||||
invoke(
|
||||
&peer,
|
||||
"mcp_report_problems",
|
||||
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
publish(&remote_file, "second-check", Some("replacement warning"));
|
||||
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("other warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
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_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();
|
||||
@@ -513,11 +542,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"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
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!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
|
||||
let plugin_host = plugins.host().clone();
|
||||
@@ -537,7 +566,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.as_deref(), Some("[]"));
|
||||
// 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}");
|
||||
plugins.host().shutdown();
|
||||
desktop.shutdown();
|
||||
}
|
||||
|
||||
@@ -341,6 +341,7 @@ 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",
|
||||
@@ -481,3 +482,117 @@ 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:#?}");
|
||||
}
|
||||
@@ -172,6 +172,7 @@ 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())),
|
||||
|
||||
@@ -223,6 +223,10 @@ pub trait Handler: Send + Sync + 'static {
|
||||
|
||||
/// Called once the response to a `method` request is queued: what it sends follows the response.
|
||||
fn after_response(&self, _method: &str) {}
|
||||
|
||||
/// Adjusts every message from the peer (responses included) before it is dispatched, e.g.
|
||||
/// to repair values the peer spells more loosely than the types that read them accept.
|
||||
fn normalize_incoming(&self, _message: &mut Value) {}
|
||||
}
|
||||
|
||||
/// A [`Handler`] that ignores notifications and rejects requests.
|
||||
@@ -585,9 +589,10 @@ impl Peer {
|
||||
.expect("spawn jsonrpc notification thread");
|
||||
}
|
||||
|
||||
// Reader: parses and dispatches; never runs handler code itself.
|
||||
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
|
||||
{
|
||||
let inner = inner.clone();
|
||||
let handler = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let seen_message = AtomicBool::new(false);
|
||||
@@ -610,7 +615,7 @@ impl Peer {
|
||||
match msg {
|
||||
Ok(Some(body)) => {
|
||||
seen_message.store(true, Ordering::Relaxed);
|
||||
dispatch(&inner, ¬if_tx, &body);
|
||||
dispatch(&inner, &*handler, ¬if_tx, &body);
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
@@ -783,15 +788,16 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
let mut msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("jsonrpc: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
handler.normalize_incoming(&mut msg);
|
||||
let Value::Object(mut obj) = msg else {
|
||||
tracing::warn!("jsonrpc: ignoring non-object message");
|
||||
return;
|
||||
|
||||
@@ -541,3 +541,29 @@ async fn output_before_the_handshake_is_reported_not_dropped() {
|
||||
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
|
||||
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn incoming_messages_are_normalized_before_dispatch() {
|
||||
struct Server;
|
||||
impl Handler for Server {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, _: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
struct Client;
|
||||
impl Handler for Client {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
|
||||
Err((codes::METHOD_NOT_FOUND, "no".into()))
|
||||
}
|
||||
fn normalize_incoming(&self, message: &mut Value) {
|
||||
if let Some(result) = message.get_mut("result") {
|
||||
*result = json!("normalized");
|
||||
}
|
||||
}
|
||||
}
|
||||
let (client, _server) = pair(Arc::new(Client), Arc::new(Server));
|
||||
let v: Value = client.request("echo", "raw", None).await.unwrap();
|
||||
assert_eq!(v, json!("normalized"));
|
||||
}
|
||||
@@ -40,6 +40,7 @@ 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")),
|
||||
@@ -548,6 +549,7 @@ mod tests {
|
||||
"Groovy",
|
||||
"Haskell",
|
||||
"Haxe",
|
||||
"Helm Template",
|
||||
"HXML",
|
||||
"HTTP",
|
||||
"IDL",
|
||||
@@ -841,6 +843,30 @@ 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();
|
||||
|
||||
@@ -391,6 +391,10 @@ fn tsserver_ran_out_of_memory(text: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Handler for ClientHandler {
|
||||
fn normalize_incoming(&self, message: &mut Value) {
|
||||
crate::uri::encode_loose_file_uris(message);
|
||||
}
|
||||
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
let Some(mgr) = self.mgr.upgrade() else { return };
|
||||
match method {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `file://` URI ⇄ path conversion (RFC 8089, VS Code-compatible encoding).
|
||||
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -22,6 +24,53 @@ const PATH: &AsciiSet = &CONTROLS
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// What RFC 3986 doesn't allow in a URI and a server may still leave unencoded in a `file:`
|
||||
/// URI (non-ASCII is always encoded); `%` stays, it starts the escapes already there.
|
||||
const LOOSE: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'[')
|
||||
.add(b'\\')
|
||||
.add(b']')
|
||||
.add(b'^')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
|
||||
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
|
||||
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
|
||||
pub fn encode_loose_file_uris(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if let Some(encoded) = encode_loose(s) {
|
||||
*s = encoded;
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
|
||||
Value::Object(map) => {
|
||||
if map.keys().any(|k| encode_loose(k).is_some()) {
|
||||
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
|
||||
}
|
||||
map.values_mut().for_each(encode_loose_file_uris);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_loose(s: &str) -> Option<String> {
|
||||
if !s.get(..5).is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) {
|
||||
return None;
|
||||
}
|
||||
match Cow::from(utf8_percent_encode(s, LOOSE)) {
|
||||
Cow::Owned(encoded) => Some(encoded),
|
||||
Cow::Borrowed(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_to_uri_string(path: &Path) -> String {
|
||||
let s = if cfg!(windows) {
|
||||
dunce::simplified(path).to_string_lossy().replace('\\', "/")
|
||||
@@ -95,6 +144,28 @@ pub use ide_core::text::is_virtual_uri;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_uris_a_server_leaves_unencoded_are_encoded() {
|
||||
// jdtls on a project under a Chinese folder (`java.usages` / references response).
|
||||
let mut v = serde_json::json!({ "result": [{
|
||||
"location": { "uri": "file:///H:/项目/online disk/A.java", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 3 } } },
|
||||
"kind": "unknown"
|
||||
}], "edit": { "changes": { "file:///tmp/目录/b.java": [] } }, "text": "项目" });
|
||||
let raw = v["result"][0]["location"].clone();
|
||||
assert!(serde_json::from_value::<lsp_types::Location>(raw).is_err(), "lsp-types rejects it as sent");
|
||||
encode_loose_file_uris(&mut v);
|
||||
let uri = v["result"][0]["location"]["uri"].as_str().unwrap();
|
||||
assert_eq!(uri, "file:///H:/%E9%A1%B9%E7%9B%AE/online%20disk/A.java");
|
||||
let location: lsp_types::Location = serde_json::from_value(v["result"][0]["location"].clone()).unwrap();
|
||||
assert!(uri_to_path(location.uri.as_str()).unwrap().ends_with("项目/online disk/A.java"));
|
||||
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
|
||||
assert_eq!(v["text"], "项目", "only file: URIs change");
|
||||
// Already encoded: untouched.
|
||||
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
|
||||
encode_loose_file_uris(&mut encoded);
|
||||
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
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.
|
||||
@@ -39,6 +40,9 @@ 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.
|
||||
@@ -47,6 +51,10 @@ 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)?;
|
||||
@@ -103,7 +111,13 @@ 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 { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
|
||||
let ours = Discovery {
|
||||
address: "100.92.72.95".parse().unwrap(),
|
||||
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)]
|
||||
@@ -118,6 +132,10 @@ 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]
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! 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>`, endpoint `/mcp`. Responses are
|
||||
//! clients too) on `127.0.0.1:<random port>` (or another address of
|
||||
//! [`ServerOptions`]), 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` (`port`, `token`, `pid`,
|
||||
//! `version`; readable only by the user) while the server runs.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `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.
|
||||
@@ -23,7 +24,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};
|
||||
pub use server::{McpServer, ServerOptions, local_address};
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -85,9 +86,15 @@ pub trait ToolRegistry: Send + Sync + 'static {
|
||||
) -> Result<String, ToolCallError>;
|
||||
}
|
||||
|
||||
/// The URL of the server at `port` for `project`.
|
||||
/// The URL of the server at `127.0.0.1:<port>` for `project`.
|
||||
pub fn endpoint_url(port: u16, project: &Path) -> String {
|
||||
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
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()))
|
||||
}
|
||||
|
||||
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
|
||||
@@ -133,6 +140,9 @@ 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"]
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -40,6 +41,8 @@ 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,
|
||||
@@ -48,6 +51,7 @@ 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>,
|
||||
@@ -60,7 +64,10 @@ pub struct McpServer {
|
||||
/// How [`McpServer::start_with`] listens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerOptions {
|
||||
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// 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
|
||||
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
|
||||
/// `None`: a free port.
|
||||
pub port: Option<u16>,
|
||||
@@ -86,15 +93,20 @@ 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 std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Some(port) => match bind(port) {
|
||||
Ok(listener) => (listener, None),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
|
||||
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
|
||||
(bind(0)?, Some(format!("port {port}: {e}")))
|
||||
}
|
||||
},
|
||||
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
|
||||
None => (bind(0)?, None),
|
||||
};
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
@@ -108,6 +120,7 @@ impl McpServer {
|
||||
};
|
||||
if let Some(path) = &discovery {
|
||||
let info = Discovery {
|
||||
address: local_address(address),
|
||||
port,
|
||||
token: token.clone(),
|
||||
pid: std::process::id(),
|
||||
@@ -120,13 +133,19 @@ 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 127.0.0.1:{port}");
|
||||
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
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
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
@@ -143,14 +162,24 @@ impl McpServer {
|
||||
&self.token
|
||||
}
|
||||
|
||||
/// The endpoint URL for `project`.
|
||||
/// The endpoint URL for `project`, at the address a client on this computer connects to.
|
||||
pub fn url(&self, project: &Path) -> String {
|
||||
crate::endpoint_url(self.port, project)
|
||||
crate::endpoint_url_at(local_address(self.address), 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();
|
||||
@@ -212,7 +241,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.port) {
|
||||
if !local_request(req.headers(), shared.address, shared.port) {
|
||||
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
|
||||
}
|
||||
if !authorized(req.headers(), &shared.token) {
|
||||
@@ -343,11 +372,16 @@ impl hyper::body::Body for EventStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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 {
|
||||
let ours = |authority: &str| {
|
||||
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
|
||||
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())
|
||||
})
|
||||
};
|
||||
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| {
|
||||
@@ -534,7 +568,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
fn only_hosts_and_origins_of_this_server_pass() {
|
||||
let headers = |pairs: &[(header::HeaderName, &str)]| {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
@@ -542,16 +576,43 @@ mod tests {
|
||||
}
|
||||
h
|
||||
};
|
||||
assert!(local_request(&headers(&[]), 80));
|
||||
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
|
||||
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(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
|
||||
loopback,
|
||||
80
|
||||
));
|
||||
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));
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -148,13 +148,14 @@ async fn post(
|
||||
)
|
||||
};
|
||||
let info = Discovery::read(discovery).map_err(|_| not_running())?;
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.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 (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, format!("127.0.0.1:{}", info.port))
|
||||
.header(header::HOST, at.to_string())
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
|
||||
.header("mcp-session-id", scope)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
|
||||
@@ -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()) };
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
s.server =
|
||||
@@ -393,3 +393,34 @@ 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}");
|
||||
}
|
||||
@@ -234,6 +234,11 @@ 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,
|
||||
|
||||
@@ -151,6 +151,67 @@ 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(¤t).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
|
||||
@@ -705,6 +766,25 @@ 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 [
|
||||
@@ -740,6 +820,23 @@ 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() {
|
||||
|
||||
@@ -932,6 +932,9 @@ 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)]
|
||||
|
||||
@@ -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_in_project(&root, &path) {
|
||||
let path = match self.resolve_background_document_in_project(&root, &path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
|
||||
};
|
||||
@@ -936,6 +936,14 @@ 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> {
|
||||
@@ -2497,7 +2505,13 @@ 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 }),
|
||||
Some(crate::FileDecoration {
|
||||
badge: item.badge,
|
||||
style: item.style,
|
||||
tooltip: item.tooltip,
|
||||
note: None,
|
||||
icon: None,
|
||||
}),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
@@ -2515,9 +2529,36 @@ 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) };
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
|
||||
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 {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
|
||||
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
|
||||
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
|
||||
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
|
||||
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
|
||||
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
|
||||
+2
-6
@@ -39,12 +39,8 @@ 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())),
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
|
||||
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
|
||||
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
|
||||
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
|
||||
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
|
||||
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
|
||||
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
|
||||
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
|
||||
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
|
||||
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
|
||||
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
|
||||
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
|
||||
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
|
||||
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
|
||||
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
|
||||
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
|
||||
d11f9cb1130f3f3aaa89514bf47ec8ece9b2ca0edf1d82c11f80b262325f16be
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
67660f5ebbfdd3967fabcc0b5c41f215d4a38d9092b91eb0166f12bccfdffffa
|
||||
48ea4414469ebd70a89da8a5df7b7bddc4d32f5c50f1759ee3cea14558073c92
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
09c810ce7ebd820ea21525dc9faea99b13e7665b308cb131510e02b86e763c9f
|
||||
0c02303182be04ed3776fb532fd8b72e6057aa0fdb92b7d235000146fef4120e
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
9b3a210440560e05326947c7d549ce691315839daea2931c6a78720f7b0d9e7c
|
||||
8162a70e848172663463119e6357984a73e54bc71a99b2845621d0733cf9333e
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
57d8b00b5853ca789fe704f21e4394d1524dd73dfd27ea4a0b34f4b0e4f1a5f8
|
||||
4524cab0ceac631153a945f6b76f7d40efd3c854740ae22c64580381e9532397
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
53e355922819b8034c93070323c7eb2b1505d3bf561dd5de6abb930fe514748c
|
||||
a92f4def072ca4cc805414516ab4f046fc54bcf11394a5413a27ea151c156174
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
7af929a1adf5d89929d66e78068253ccde9ad5cedee329c2d4db6b7a5e29790e
|
||||
994fca61bc5a10f0229301406af76c188a9cb2f593db85a403c1ece746e9f7b5
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
511fab979382c5d64054ba4f5fdfc98d50e252dfd5896265ee589336bf8e7d70
|
||||
48eaf97f57cc08c3854a1effa37ebf29defca4782f76c7de1da6ef9a02c23441
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
444497128ec5e1e01229a93d2340b49ccc5bc27e67defb2c4fe19683042442a3
|
||||
b896043cf261ff4bb19977f4648a736907fa897b8d4f0804077678a5895d1845
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub(crate) mod cmake;
|
||||
mod dotnet;
|
||||
mod zig;
|
||||
|
||||
use crate::RunConfig;
|
||||
use ide_core::project_roots::expand_members;
|
||||
@@ -53,6 +54,8 @@ 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);
|
||||
@@ -70,6 +73,7 @@ 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);
|
||||
@@ -1326,6 +1330,38 @@ 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>) {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,10 @@ pub struct TestCaseReport {
|
||||
pub duration_ms: Option<f64>,
|
||||
/// Failure / error / skip message.
|
||||
pub message: Option<String>,
|
||||
/// Failure stack trace and captured output.
|
||||
/// Failure / error stack trace.
|
||||
pub output: Option<String>,
|
||||
/// What the test printed (`<system-out>`, `<system-err>`).
|
||||
pub stdout: Option<String>,
|
||||
}
|
||||
|
||||
fn decode(s: &str) -> String {
|
||||
@@ -124,11 +126,13 @@ pub fn parse_report(xml: &str) -> Vec<TestCaseReport> {
|
||||
if let Some(name) = tag.strip_prefix('/') {
|
||||
match name.trim() {
|
||||
"testcase" => out.extend(current.take()),
|
||||
"failure" | "error" | "rerunFailure" | "rerunError" | "skipped" | "system-out" | "system-err" => {
|
||||
name @ ("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 o = c.output.get_or_insert_with(String::new);
|
||||
let field = if name.starts_with("system-") { &mut c.stdout } else { &mut c.output };
|
||||
let o = field.get_or_insert_with(String::new);
|
||||
if !o.is_empty() {
|
||||
o.push('\n');
|
||||
}
|
||||
@@ -160,6 +164,7 @@ pub fn parse_report(xml: &str) -> Vec<TestCaseReport> {
|
||||
duration_ms,
|
||||
message: None,
|
||||
output: None,
|
||||
stdout: None,
|
||||
};
|
||||
if self_closing {
|
||||
out.push(case);
|
||||
@@ -238,6 +243,8 @@ mod tests {
|
||||
<failure message="expected: <1> but was: <2>" 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"/>
|
||||
@@ -254,10 +261,13 @@ 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].output.as_deref(), Some("hello <world>"));
|
||||
assert_eq!(cases[1].stdout.as_deref(), Some("hello <world>"));
|
||||
assert_eq!(cases[1].output, None);
|
||||
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"));
|
||||
|
||||
@@ -1173,3 +1173,36 @@ 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");
|
||||
}
|
||||
@@ -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, own_typescript};
|
||||
use crate::npm::{has_ts_api, native_typescript, 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,6 +722,16 @@ 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) {
|
||||
@@ -957,10 +967,15 @@ 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();
|
||||
|
||||
@@ -1135,6 +1150,16 @@ 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"
|
||||
|
||||
@@ -749,14 +749,26 @@ pub(crate) fn workspace_tsdk(root: &Path) -> Option<PathBuf> {
|
||||
|
||||
fn typescript(root: Option<&Path>) -> Vec<Sdk> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(lib) = root.and_then(workspace_tsdk) {
|
||||
// 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) {
|
||||
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()).trim().to_owned(),
|
||||
name: format!(
|
||||
"TypeScript {}{}",
|
||||
version.clone().unwrap_or_default(),
|
||||
if crate::npm::has_ts_api(&lib) { "" } else { " (native)" }
|
||||
)
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
.to_owned(),
|
||||
version,
|
||||
source: "workspace".into(),
|
||||
home: lib.parent().map(Into::into),
|
||||
|
||||
@@ -10,6 +10,41 @@ 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))
|
||||
|
||||
@@ -1325,6 +1325,85 @@ 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();
|
||||
|
||||
@@ -65,5 +65,26 @@ fn bench(c: &mut Criterion) {
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
// No .ignore file: the query's subtree masks must prevent traversal themselves.
|
||||
fn excluded_subtrees(c: &mut Criterion) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for module in 0..100 {
|
||||
let root = dir.path().join(format!("modules/m{module}"));
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/main.go"), "needle").unwrap();
|
||||
for dependency in 0..50 {
|
||||
let dep = root.join(format!("node_modules/dep{dependency}/lib"));
|
||||
std::fs::create_dir_all(&dep).unwrap();
|
||||
std::fs::write(dep.join("index.js"), "needle").unwrap();
|
||||
}
|
||||
}
|
||||
let q = SearchQuery { exclude: vec!["**/node_modules/**".into()], ..query("needle", false) };
|
||||
assert_eq!(run(dir.path(), &q), 100);
|
||||
let mut group = c.benchmark_group("search_excluded_subtrees");
|
||||
group.sample_size(15);
|
||||
group.bench_function("5k_dependencies", |b| b.iter(|| black_box(run(dir.path(), &q))));
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench, excluded_subtrees);
|
||||
criterion_main!(benches);
|
||||
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user