Compare commits

..
Author SHA1 Message Date
l0ng-ai cbb7bc952b fix(vim): draw the Normal-mode block cursor solid, with the character in the background colour
The focused block cursor was a 60% caret-coloured fill. On dark themes the caret is light
grey, so it read as a slightly brighter character and the cursor was easy to lose. It is now a
solid block in the caret colour with the character under it in the editor background colour,
as IdeaVim's block caret; the unfocused 1px outline and partial blocks are unchanged.

Fixes #3197
2026-11-14 01:27:00 +08:00
333 changed files with 1361 additions and 11274 deletions

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=271.2085.0
KOTLIN_LSP=263.7084.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
+1 -1
View File
@@ -119,7 +119,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
+4 -23
View File
@@ -144,13 +144,6 @@ fn bracket_pos(s: &str) -> Option<(u32, Option<u32>, &str)> {
Some((line, col, rest.trim_start()))
}
/// `/C:/p/A.java` → `C:/p/A.java`: Maven and file URIs put a `/` before the drive letter on Windows.
/// Joined as is, it would become the drive-relative `C:p/A.java`.
fn drive_path(p: &str) -> &str {
let b = p.as_bytes();
if b.len() > 2 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { &p[1..] } else { p }
}
/// `file:///p/A.kt` → `/p/A.kt` (percent-decoded).
fn from_file_uri(s: &str) -> String {
let Some(rest) = s.strip_prefix("file://") else { return s.to_owned() };
@@ -169,7 +162,9 @@ fn from_file_uri(s: &str) -> String {
out.push(bytes[i]);
i += 1;
}
drive_path(&String::from_utf8_lossy(&out)).to_owned()
let path = String::from_utf8_lossy(&out).into_owned();
// `file:///C:/x` on Windows.
if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' { path[1..].to_owned() } else { path }
}
/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix.
@@ -227,7 +222,7 @@ fn maven_line(line: &str) -> Option<RawMessage> {
&& looks_like_source_path(path)
&& let Some((ln, col, msg)) = bracket_pos(&format!("[{pos}"))
{
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(drive_path(path), ln, col));
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(path, ln, col));
}
if let Some(m) = kotlin_line(rest) {
return Some(m);
@@ -461,20 +456,6 @@ mod tests {
assert_eq!(out[1].unit, ColUnit::Char);
}
#[test]
fn maven_windows_paths_drop_the_slash_before_the_drive() {
let out = parse(
Format::Maven,
"[ERROR] /C:/dev/p/src/main/java/a/A.java:[18,41] package a.b does not exist\n\
[ERROR] C:\\dev\\p\\B.java:[3,8] cannot find symbol\n\
[ERROR] file:///C:/dev/p/K.kt:4:9 Unresolved reference 'x'.\n",
);
assert_eq!(out.len(), 3, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:/dev/p/src/main/java/a/A.java", 18, Some(41)));
assert_eq!(pos(&out[1]), (Severity::Error, "C:\\dev\\p\\B.java", 3, Some(8)));
assert_eq!(pos(&out[2]), (Severity::Error, "C:/dev/p/K.kt", 4, Some(9)));
}
#[test]
fn maven_detail_lines_with_level_prefix_and_kotlin() {
let out = parse(
+1 -7
View File
@@ -291,7 +291,7 @@ pub enum NewTaskApprovals {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on [`Self::address`] while the IDE runs.
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
@@ -300,9 +300,6 @@ pub struct McpServerSettings {
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
/// address of this computer, or `0.0.0.0` / `::` for every address.
pub address: String,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
@@ -315,8 +312,6 @@ pub struct McpServerSettings {
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
/// The default of [`McpServerSettings::address`]: this computer only.
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
impl Default for McpServerSettings {
fn default() -> Self {
@@ -325,7 +320,6 @@ impl Default for McpServerSettings {
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
address: DEFAULT_MCP_ADDRESS.to_owned(),
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
+12 -46
View File
@@ -324,17 +324,6 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
/// Connection target: a data source id (its configured database), or
/// `<id>:<database>` for another database of the same server. Data source ids
/// never contain `:`.
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
fn tunneled(driver: DbDriver) -> bool {
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
}
/// The database host and port the SSH host forwards to.
fn tunnel_target(source: &DataSource) -> (&str, u16) {
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
}
pub fn split_target(target: &str) -> (&str, Option<&str>) {
match target.split_once(':') {
Some((id, db)) if !db.is_empty() => (id, Some(db)),
@@ -666,7 +655,9 @@ impl DbManager {
ssh_secret: Option<String>,
shared: bool,
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
return Ok((source.clone(), None));
};
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
@@ -677,25 +668,8 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (target_host, target_port) = tunnel_target(source);
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
/// opened with the settings typed in the data source.
async fn ssh_session(
&self,
source: &DataSource,
ssh: &DbSsh,
ssh_secret: Option<String>,
) -> IdeResult<Arc<ide_ssh::Session>> {
let config = ssh.config.trim();
Ok(if config.is_empty() {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
@@ -714,22 +688,14 @@ impl DbManager {
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
})
}
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
/// the database host and port, without logging in to the database.
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
let source = &source.resolved()?;
let ssh = source
.ssh
.as_ref()
.filter(|s| s.enabled && tunneled(source.driver))
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (host, port) = tunnel_target(source);
drop(Tunnel::open(session.clone(), host, port).await?);
Ok(format!("{} reaches {host}:{port}", session.label()))
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
-96
View File
@@ -1,96 +0,0 @@
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
//! the SSH host reaching the database host and port, with no database login.
use ide_core::error::ErrorKind;
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
use ide_ssh::{SshFiles, SshManager};
use std::sync::Arc;
use tokio::net::TcpListener;
struct Env {
_tmp: tempfile::TempDir,
db: DbManager,
ssh_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
root.join("ssh-secrets.json"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
Env { _tmp: tmp, db, ssh_port }
}
impl Env {
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
fn source(&self, db_port: u16) -> DataSource {
DataSource {
id: "pg".into(),
name: "pg".into(),
driver: DbDriver::Postgres,
host: "127.0.0.1".into(),
port: Some(db_port),
user: "app".into(),
ssh: Some(DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth: DbSshAuth::Password,
..Default::default()
}),
..Default::default()
}
}
}
/// A port something listens on (and never speaks a database protocol).
async fn listening() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_tunnel_is_tested_without_a_database_login() {
let env = env().await;
let (_db, db_port) = listening().await;
let source = env.source(db_port);
// First contact: the host key has to be trusted, as with Test Connection.
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
env.db.trust_ssh_host("pg").await.unwrap();
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
let env = env().await;
let (listener, closed) = listening().await;
drop(listener);
let source = env.source(closed);
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
env.db.trust_ssh_host("pg").await.unwrap();
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
}
#[tokio::test]
async fn a_source_without_a_tunnel_has_nothing_to_test() {
let env = env().await;
let mut source = env.source(5432);
source.ssh.as_mut().unwrap().enabled = false;
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
-15
View File
@@ -42,10 +42,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_test_tunnel", |c| async move {
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
test_tunnel(&c.host, &c.window, source, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
@@ -216,17 +212,6 @@ pub async fn test(
db.test(&source, password, ssh_secret).await
}
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
pub async fn test_tunnel(
host: &Host,
window: &str,
source: DataSource,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test_tunnel(&source, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
+2 -54
View File
@@ -74,15 +74,9 @@ pub async fn lsp_completion(
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
.await
.unwrap_or_default();
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return if covered {
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
} else {
result
};
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
@@ -315,13 +309,9 @@ 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.
@@ -362,30 +352,6 @@ 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 || {
@@ -2170,24 +2136,6 @@ 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());
+1 -12
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
@@ -89,16 +89,6 @@ pub async fn tools_install(
Ok(info)
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
let tools = tools(host, window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Removes a tool the IDE installed.
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
let tools = tools(host, window)?;
@@ -195,7 +185,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
});
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
d.add("jdk_download_releases", |c| async move {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
-21
View File
@@ -22,27 +22,6 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
.await
}
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
/// to no repository are ignored, and an empty list is the full status.
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status_paths(&paths)
})
.await
}
mod commands;
pub use commands::*;
+1 -1
View File
@@ -1507,7 +1507,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
+13 -77
View File
@@ -1642,9 +1642,6 @@ impl HostApi for PluginApi {
) -> Result<(), HostError> {
self.set_decorations(path, set, Vec::new(), item, true)
}
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
self.set_file_icon_decorations(set, items)
}
fn vcs_log(
&self,
root: &Path,
@@ -4127,9 +4124,6 @@ impl PluginApi {
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4138,14 +4132,24 @@ impl PluginApi {
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self.decoration_root(path);
let root = self
.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
Some(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path.to_path_buf()),
set_id: set.into(),
tree,
items,
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4173,60 +4177,6 @@ impl PluginApi {
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
fn decoration_root(&self, path: &Path) -> PathBuf {
self.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
}
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
if set.is_empty() || set.len() > 128 {
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
}
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
}
let mut by_path = BTreeMap::new();
for (path, icon) in items {
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
by_path.insert(path, icon);
}
let mut all = self.shared.decorations.lock();
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
sets.retain(|s| !(s.tree && s.set_id == set));
for (path, icon) in by_path {
let root = self.decoration_root(&path);
sets.push(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path),
set_id: set.into(),
tree: true,
items: Vec::new(),
file: Some(ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: None,
note: None,
icon: Some(icon),
}),
});
}
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
}
all.insert(self.id().into(), sets.clone());
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
}
fn decoration_style(style: &str) -> bool {
matches!(
@@ -4380,8 +4330,7 @@ mod workbench_tests {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None,
icon: None,
note: None
})
)
.is_err()
@@ -4391,7 +4340,6 @@ mod workbench_tests {
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
icon: None,
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
@@ -4405,18 +4353,6 @@ mod workbench_tests {
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.file_icon_decorations(
"icons",
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
)
.unwrap();
let icons = api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "icons")
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
.collect::<Vec<_>>();
assert_eq!(icons.len(), 2);
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+2 -25
View File
@@ -396,37 +396,15 @@ impl PluginHost {
}
}
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
self.provider_plugins(kind, &doc.language)
.into_iter()
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
self.provide_completion_for_editor(doc, at, trigger).1
}
pub(crate) fn provide_completion_for_editor(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> (bool, Vec<(String, CompletionItem)>) {
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
let items = self
.feature_documents(doc, Some(at))
self.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
if self.has_document_providers(ProviderKind::Completion, &doc) {
covered = true;
}
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
@@ -441,8 +419,7 @@ impl PluginHost {
Some((p, i))
})
})
.collect();
(covered, items)
.collect()
}
pub fn provide_code_actions(
+5 -20
View File
@@ -743,7 +743,6 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
#[serde(default)]
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
@@ -755,19 +754,13 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
/// icons still win).
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
if d.patterns.is_empty() {
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
} else {
file_patterns(&d.patterns, &what)?;
}
file_patterns(&d.patterns, &what)?;
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
@@ -776,16 +769,8 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
let revision = blake3::hash(&bytes).to_hex().to_string();
let json = serde_json::json!({
"id": d.id,
"patterns": d.patterns,
"dark": d.icon_dark.is_some(),
"revision": revision,
});
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
let mut out = UiDecl::new(d.id.clone(), label, json);
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
out.files = files;
out.assets = assets;
Ok(out)
+9 -20
View File
@@ -447,11 +447,6 @@ impl Host {
.collect();
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
// A repository folder moved or restored into the project: no `.git` event is
// written inside it before the watcher sees the new directory, its creation is
// the signal. A `git clone` / `git init` inside the project already arrives
// through the `.git` paths above; this covers the whole tree being brought in.
git_dirs |= batch.created.iter().any(|p| p.join(".git").exists());
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
}
@@ -869,20 +864,6 @@ fn scan_repositories(
sink: &SharedSink,
build_cache: &Path,
) {
match discover_repositories(root, settings, build_cache) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
/// The repository containing `root` and those nested below it or below a content root.
/// The project-open scan and the watcher's rescan share these skip rules.
fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Path) -> IdeResult<Repos> {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
@@ -894,7 +875,15 @@ fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Pa
};
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
Repos::discover_roots(&roots, &skip)
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
impl Project {
+1 -2
View File
@@ -270,8 +270,7 @@ impl Dispatcher {
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
ops::register_all(&mut d);
d
}
-1
View File
@@ -475,7 +475,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_status_paths"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
@@ -99,21 +99,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
let toml_path = root.join("a.toml");
std::fs::write(&toml_path, "k = 1\n").unwrap();
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
assert_eq!(
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
.await
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Unavailable
);
let sql_path = root.join("q.sql");
std::fs::write(&sql_path, "SELECT 1;").unwrap();
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
assert_eq!(
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
.parameters,
+2 -45
View File
@@ -2554,8 +2554,7 @@ 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();
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.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let item = |host: &PluginHost| {
@@ -2993,18 +2992,13 @@ 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", 4));
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
// Undeclared views are refused before any code runs.
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
@@ -3760,43 +3754,6 @@ path = "templates/new.draw"
assert!(f.host.web_page("web-demo", "draw").is_err());
}
#[test]
fn file_icons_without_patterns_require_api_1_8_and_project_assets() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("dynamic-icons");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(pkg.join("icons/helm-dir.svg"), r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>"#)
.unwrap();
let write_manifest = |api: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "dynamic-icons"
name = "Dynamic Icons"
version = "0.1.0"
engines.openidea = "{api}"
[[contributes.fileIcons]]
id = "helm-dir"
icon = "icons/helm-dir.svg"
"#
),
)
.unwrap();
};
write_manifest("^1.7");
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("1.8"), "{err:?}");
write_manifest("^1.8");
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let assets = f.host.assets("dynamic-icons", "fileIcon/helm-dir/").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<circle"));
}
#[test]
fn file_templates_fixture_installs_without_write_capabilities() {
let f = fixture(Limits::default());
+16 -49
View File
@@ -367,7 +367,6 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
use ide_core::{Diagnostic, Pos, Range, Severity};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let tmp = tempfile::tempdir().unwrap();
let desktop = Arc::new(
@@ -427,70 +426,42 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
"severity": "warning", "message": message, "source": "remote-check", "code": null
}]})
};
let publish = |path: &Path, source: &str, message: Option<&str>| {
let items = message
.map(|message| Diagnostic {
range: Range::new(Pos::new(0, 1), Pos::new(0, 3)),
severity: Severity::Warning,
message: message.into(),
source: Some(source.into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.into_iter()
.collect();
host.project("main").unwrap().sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: source.into(),
items,
});
};
let files = json!([
problem(&remote_file, "remote warning"),
problem(&other_file, "other warning"),
problem(&outside_file, "outside warning")
]);
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
// UI reports are not the source of the plugin's host-owned snapshot.
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
publish(&remote_file, "remote-check", Some("remote warning"));
publish(&other_file, "remote-check", Some("other warning"));
publish(&outside_file, "remote-check", Some("outside warning"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("other warning"), "{problems}");
assert!(problems.contains("outside warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
for path in ["remote.txt", remote_file.to_str().unwrap()] {
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(!problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
}
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
publish(&remote_file, "second-check", Some("second warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
publish(&remote_file, "remote-check", None);
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(!problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
publish(&remote_file, "second-check", Some("replacement warning"));
invoke(
&peer,
"mcp_report_problems",
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
)
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("replacement warning"));
assert!(!problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
publish(&remote_file, "second-check", None);
publish(&other_file, "remote-check", None);
publish(&outside_file, "remote-check", None);
assert!(!problems.as_str().unwrap().contains("other warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
@@ -542,11 +513,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
stream.read_to_string(&mut response).await.unwrap();
assert!(response.contains("You can close this tab"));
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
publish(&remote_file, "remote-check", Some("remote warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
invoke(&peer, "close_project", json!({})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
publish(&remote_file, "remote-check", Some("remote warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
peer.kill();
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
let plugin_host = plugins.host().clone();
@@ -566,11 +537,7 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
})
.await
.unwrap();
// Disconnect clears desktop reports, not the still-live host project snapshot.
let problems = result.as_deref().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("remote-check"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
assert_eq!(result.as_deref(), Some("[]"));
plugins.host().shutdown();
desktop.shutdown();
}
-115
View File
@@ -341,7 +341,6 @@ async fn local_deployment_runs_on_the_project_host() {
"db_save_sources",
"db_set_password",
"db_test",
"db_test_tunnel",
"db_set_ssh_secret",
"db_trust_ssh_host",
"db_connected",
@@ -482,117 +481,3 @@ async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
assert!(rpc::client_commands().contains(&"term_spawn"));
plugins.host().shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn renames_a_branch_with_the_clients_argument_names() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir(&root).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git").args(args).current_dir(&root).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
};
git(&["init", "-q", "-b", "main"]);
std::fs::write(root.join("a.txt"), "a\n").unwrap();
git(&["add", "."]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]);
git(&["branch", "feature"]);
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
invoke(&peer, "open_project", json!({"path": root})).await.unwrap();
invoke(&peer, "project_trust_set", json!({"path": root, "trusted": true, "folder": false})).await.unwrap();
// The argument names the desktop client sends (`vcsRenameBranch` in src/bindings.ts).
invoke(
&peer,
"vcs_rename_branch",
json!({"repo": null, "from": "feature", "to": "renamed", "unsetUpstream": false}),
)
.await
.unwrap();
assert_eq!(
git(&["branch", "--format=%(refname:short)", "--list"]).lines().collect::<Vec<_>>(),
["main", "renamed"]
);
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
/// A handler reads its arguments by the names the desktop client sends: `src/bindings.ts`
/// passes them in camelCase, and a name the client never sends reads as `null` (an
/// `Option` silently `None`, anything else an "invalid type: null" error).
#[test]
fn handlers_read_the_argument_names_the_client_sends() {
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let bindings = std::fs::read_to_string(repo.join("src/bindings.ts")).unwrap();
let mut sent = HashMap::<&str, BTreeSet<&str>>::new();
for part in bindings.split("__TAURI_INVOKE(\"").skip(1) {
let (command, rest) = part.split_once('"').unwrap();
let args = rest.strip_prefix(", {").and_then(|r| r.split_once('}')).map_or("", |(args, _)| args);
let names = args.split(',').map(|a| a.split(':').next().unwrap().trim()).filter(|a| !a.is_empty());
sent.insert(command, names.collect());
}
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let mut files = Vec::new();
sources(&repo.join("crates/ide-host/src"), &mut files);
let mut checked = 0;
let mut wrong = Vec::new();
for file in files {
let src = std::fs::read_to_string(&file).unwrap();
// `.add("name", …)`, also with the name on the next line.
let starts: Vec<usize> = src
.match_indices(".add(")
.map(|(i, m)| i + m.len())
.filter_map(|i| src[i..].trim_start().strip_prefix('"').map(|rest| src.len() - rest.len()))
.collect();
for (n, &start) in starts.iter().enumerate() {
let body = &src[start..starts.get(n + 1).copied().unwrap_or(src.len())];
let (command, body) = body.split_once('"').unwrap();
let Some(sent) = sent.get(command) else { continue };
checked += 1;
for reader in ["c.arg", "c.sender", "c.channel"] {
for (at, _) in body.match_indices(reader) {
let rest = &body[at + reader.len()..];
let rest = match rest.strip_prefix("::<") {
Some(generic) => &generic[generic.find(">(").unwrap() + 1..],
None => rest,
};
let Some(name) = rest.strip_prefix("(\"").and_then(|r| r.split_once('"')).map(|(n, _)| n) else {
continue;
};
if !sent.contains(name) {
wrong.push(format!("{command} reads `{name}`; the client sends {sent:?}"));
}
}
}
}
}
assert!(checked > 400, "found only {checked} handlers with client bindings");
assert!(wrong.is_empty(), "{wrong:#?}");
}
-1
View File
@@ -172,7 +172,6 @@ async fn restricted_vcs_commands_run_no_program_of_the_repository() {
}
let calls: Vec<Call> = vec![
call!("vcs_status", "", ops::vcs::status(host, w)),
call!("vcs_status_paths", "", ops::vcs::status_paths(host, w, vec![file.clone()])),
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
@@ -1,54 +0,0 @@
//! A repository folder moved into an open project is registered while the project is open:
//! the folder brings no `.git` event of its own, so the watcher's "created path" is what the
//! rescan has to answer to (the open scan already ran).
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use std::{path::Path, sync::Arc, time::Duration};
fn git_init(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
let status = std::process::Command::new("git").args(["init", "-q"]).current_dir(dir).status();
assert!(status.is_ok_and(|status| status.success()), "git init in {}", dir.display());
}
async fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while !done() {
assert!(tokio::time::Instant::now() < deadline, "timed out waiting for {what}");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn moving_a_repository_into_the_project_registers_it() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
git_init(&root);
// Prepared outside the project, as a restored or moved folder would be.
let prepared = base.join("prepared");
git_init(&prepared);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
// The scan on project open runs in the background: wait for it before moving anything in.
wait_until("the project's own repository", || project.repos().roots().len() == 1).await;
std::fs::rename(&prepared, root.join("moved")).unwrap();
wait_until("the moved repository to be registered", || {
project.repos().roots().iter().any(|r| r.ends_with("moved"))
})
.await;
}
-4
View File
@@ -31,7 +31,3 @@ criterion = { version = "0.5", default-features = false }
[[bench]]
name = "index"
harness = false
[[bench]]
name = "startup"
harness = false
-64
View File
@@ -1,64 +0,0 @@
//! `cargo bench -p ide-index --bench startup`: open a persisted 20k-file
//! project and wait for the index to become idle. This is the full warm-start
//! path (columnar snapshot, directory/file manifest, lazy redb).
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use ide_core::NullSink;
use ide_index::ProjectIndex;
use ide_language::{LanguageRegistry, Syntax};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const FILES: usize = 20_000;
fn project(root: &Path) {
for i in 0..FILES {
let dir = root.join(format!("mod{}/src", i % 50));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("File{i}.rs")), format!("fn symbol{i}() {{}}\n")).unwrap();
}
}
fn bench(c: &mut Criterion) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache = tmp.path().join("cache");
project(&root);
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
let syntax = Arc::new(Syntax::new());
let open = || {
ProjectIndex::open(
&root,
&cache,
Vec::<PathBuf>::new(),
vec![],
languages.clone(),
syntax.clone(),
Arc::new(NullSink),
)
.unwrap()
};
let first = open();
assert!(first.wait_idle(Duration::from_secs(30)));
drop(first);
let mut group = c.benchmark_group("startup_20k");
group.sample_size(10);
group.bench_function("reopen_wait_idle", |b| {
b.iter(|| {
let index = open();
assert!(index.wait_idle(Duration::from_secs(30)));
black_box(index.status().files)
})
});
group.finish();
}
criterion_group! {
name = benches;
config = Criterion::default().measurement_time(Duration::from_secs(3));
targets = bench
}
criterion_main!(benches);
-91
View File
@@ -24,19 +24,6 @@ pub(crate) fn char_mask(s: &str) -> u64 {
m
}
/// A relative index key must stay below the project root.
pub(crate) fn safe_rel(rel: &str) -> bool {
let mut any = false;
for component in Path::new(rel).components() {
if matches!(component, std::path::Component::Normal(_)) {
any = true;
} else {
return false;
}
}
any
}
#[inline]
pub(crate) fn mask_bit(b: u8) -> u64 {
match b.to_ascii_lowercase() {
@@ -566,67 +553,6 @@ impl IndexData {
self.by_path.get(path).copied()
}
/// Reserves the flat tables for a bulk warm-start hydration.
pub(crate) fn reserve(&mut self, files: usize, symbols: usize, dirs: usize) {
self.files.reserve(files);
self.by_path.reserve(files);
self.dirs.reserve(dirs);
self.hot.rows.reserve(files);
self.dirs_hot.rows.reserve(dirs);
self.syms.head.reserve(symbols);
self.syms.container.reserve(symbols);
self.syms.kind.reserve(symbols);
self.syms.range.reserve(symbols);
}
/// Hydrates a validated warm-start snapshot in one pass, without per-file
/// duplicate checks, ancestor walks or compaction checks.
pub(crate) fn load_bulk(
&mut self,
entries: Vec<(FileEntry, Vec<StoredSymbol>)>,
dirs: Vec<(Box<str>, PathBuf, u32)>,
) {
self.files = Vec::with_capacity(entries.len());
self.by_path = HashMap::with_capacity(entries.len());
self.hot = HotFiles::default();
self.dirs_hot = HotDirs::default();
self.syms = SymTable::default();
self.dirs = HashMap::with_capacity(dirs.len());
let symbols: usize = entries.iter().map(|(_, symbols)| symbols.len()).sum();
self.reserve(entries.len(), symbols, dirs.len());
self.symbol_count = 0;
for (slot, (mut entry, symbols)) in entries.into_iter().enumerate() {
let (sym_start, sym_mask) = self.syms.push(&symbols);
entry.sym_start = sym_start;
entry.sym_len = symbols.len() as u32;
self.symbol_count += entry.sym_len;
let rel = self.hot.append(&entry.rel);
self.hot.set(
slot as u32,
FileRow {
rel,
name_start: entry.name_start,
sym_start,
sym_len: entry.sym_len,
sym_mask,
rel_mask: entry.rel_mask,
name_mask: entry.name_mask,
dir_mask: char_mask(entry.dir()),
ascii: entry.rel.is_ascii(),
live: true,
},
);
self.by_path.insert(entry.path.clone(), slot as u32);
self.files.push(Some(entry));
}
self.file_count = self.files.len() as u32;
for (rel, path, count) in dirs {
let row = self.dirs_hot.push(&rel, &path);
self.dirs.insert(rel, (path, count, row));
}
self.free.clear();
}
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
FileRecord {
@@ -867,23 +793,6 @@ mod tests {
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
}
#[test]
fn bulk_load_matches_incremental_insert() {
let mut incremental = IndexData::default();
insert(&mut incremental, "a/b.rs", 2);
insert(&mut incremental, "c.rs", 1);
assert_consistent(&incremental);
let mut bulk = IndexData::default();
bulk.load_bulk(vec![entry("a/b.rs", 2), entry("c.rs", 1)], vec![("a/".into(), PathBuf::from("/r/a"), 1)]);
assert_consistent(&bulk);
assert_eq!((bulk.file_count, bulk.symbol_count), (2, 3));
assert_eq!(bulk.hot.text(), incremental.hot.text());
assert_eq!(bulk.files.len(), incremental.files.len());
assert_eq!(bulk.slot(Path::new("/r/a/b.rs")), incremental.slot(Path::new("/r/a/b.rs")));
assert_eq!(bulk.dirs["a/"].1, 1);
}
#[test]
fn dirs_follow_inserts_and_removals() {
let mut d = IndexData::default();
-45
View File
@@ -138,22 +138,6 @@ impl PathFilter {
&self.config.0 == extra_roots && self.config.1 == excludes
}
/// Stable fingerprint of the persisted-scope configuration; a start-up
/// snapshot is only trusted when this still matches.
pub(crate) fn stamp(&self) -> String {
let mut stamp = format!("include_root={}\n", self.config.0.include_root);
for folder in &self.config.0.folders {
stamp.push_str(&folder.to_string_lossy());
stamp.push('\n');
}
stamp.push_str("--\n");
for exclude in &self.config.1 {
stamp.push_str(exclude);
stamp.push('\n');
}
stamp
}
pub(crate) fn reload(&self) -> Self {
Self::new(&self.root, &self.config.0, &self.config.1)
}
@@ -162,11 +146,6 @@ impl PathFilter {
&self.root
}
/// Whether content roots outside the project root are indexed with it.
pub(crate) fn has_extra_roots(&self) -> bool {
!self.extra.is_empty()
}
/// The project root, then the extra content roots.
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
std::iter::once(self.root.as_path())
@@ -248,30 +227,6 @@ impl PathFilter {
dir.is_some_and(|d| gitignore.matched_path_or_any_parents(d, true).is_ignore())
}
/// All directories at or below `dir` that the index would walk, in walk order.
pub(crate) fn dirs(&self, dir: &Path) -> Vec<PathBuf> {
let Some((root, _, _)) = self.base_of(dir) else { return Vec::new() };
let root = root.to_path_buf();
let excludes = self.excludes.clone();
let walk_root = root.clone();
let walk_excludes = excludes.clone();
let walker = WalkBuilder::new(dir)
.hidden(false)
.parents(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(ide_core::ignored::inside_git_repo(dir))
.follow_links(false)
.filter_entry(move |e| {
let name = e.file_name().to_string_lossy();
!Self::skip_name(&name) && !glob_excluded(&walk_excludes, &walk_root, e.path())
})
.build();
walker.flatten().filter(|e| e.file_type().is_some_and(|t| t.is_dir())).map(|e| e.into_path()).collect()
}
/// All indexable files at or below `dir`, walked in parallel.
/// Stops early (returning what was found so far) once `cancel` is set.
pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec<Found> {
+39 -568
View File
@@ -1,408 +1,75 @@
//! Persistence of file records in redb, so a restart only re-parses changed files.
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{PathFilter, mtime_of};
use ide_core::{Range, SymbolKind};
use crate::data::FileRecord;
use parking_lot::RwLock;
use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition, TableError};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
/// Bump when [`FileRecord`] or its encoding changes.
const SCHEMA: &str = "openidea-index-3";
/// The real Git directory for a checkout/worktree (`root/.git` may be a file
/// containing `gitdir: ...`).
fn git_dir(root: &Path) -> Option<std::path::PathBuf> {
let dot_git = root.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
let text = std::fs::read_to_string(&dot_git).ok()?;
let target = text.strip_prefix("gitdir:")?.trim();
let path = Path::new(target);
Some(if path.is_absolute() { path.to_owned() } else { root.join(path) })
}
/// External ignore/config inputs, including candidates that do not exist yet.
/// These are outside the directory manifest (`.git` is skipped), so the full
/// path set must match at warm start.
pub(crate) fn external_ignore_candidates(root: &Path) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
if let Some(git) = git_dir(root) {
out.insert(git.join("config"));
out.insert(git.join("info/exclude"));
}
out.insert(root.join(".git/config"));
out.insert(root.join(".git/info/exclude"));
if let Some(path) = ignore::gitignore::gitconfig_excludes_path() {
out.insert(path);
}
out.insert(std::path::PathBuf::from("/etc/gitconfig"));
if let Some(path) = std::env::var_os("GIT_CONFIG_GLOBAL") {
out.insert(path.into());
}
if let Some(home) = std::env::var_os("HOME") {
let home = std::path::PathBuf::from(home);
out.insert(home.join(".gitconfig"));
out.insert(
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| home.join(".config"))
.join("git/config"),
);
}
out.into_iter().collect()
}
/// Existing per-directory ignore files. Adding/removing one changes the
/// directory mtime, which the manifest already validates before this list is used.
fn per_dir_ignore_candidates(root: &Path, dirs: &[String]) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
for rel in dirs {
let dir = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
for name in [".gitignore", ".ignore"] {
let path = dir.join(name);
if path.is_file() {
out.insert(path);
}
}
}
out.into_iter().collect()
}
/// One file's fixed-size columns in the start-up snapshot.
#[derive(Clone, Copy, Serialize, Deserialize, Default)]
struct FileMeta {
mtime: u64,
size: u64,
hash: [u8; 32],
ignored: bool,
/// End of this file's symbols in [`Snapshot::symbols`].
sym_end: u32,
}
/// One symbol's fixed-size columns; names and containers share [`Snapshot::text`].
#[derive(Clone, Copy, Serialize, Deserialize)]
struct FlatSymbol {
name_start: u32,
name_len: u32,
container_start: u32,
container_len: u32,
kind: SymbolKind,
range: Range,
}
/// A columnar copy of the index for fast start-up: a handful of contiguous
/// arrays and two string arenas instead of one postcard record per redb key.
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct Snapshot {
rels: String,
rel_ends: Vec<u32>,
meta: Vec<FileMeta>,
symbols: Vec<FlatSymbol>,
text: String,
dirs: String,
dir_ends: Vec<u32>,
dir_mtimes: Vec<u64>,
dir_counts: Vec<u32>,
filter: String,
stamp: String,
ignore_paths: String,
ignore_ends: Vec<u32>,
ignore_mtimes: Vec<u64>,
ignore_sizes: Vec<u64>,
ignore_present: Vec<bool>,
ignore_external: Vec<bool>,
}
impl Snapshot {
pub(crate) fn len(&self) -> usize {
self.meta.len()
}
pub(crate) fn filter(&self) -> &str {
&self.filter
}
pub(crate) fn dirs_len(&self) -> usize {
self.dir_mtimes.len()
}
pub(crate) fn dir(&self, dir: usize) -> &str {
let start = if dir == 0 { 0 } else { self.dir_ends[dir - 1] as usize };
&self.dirs[start..self.dir_ends[dir] as usize]
}
pub(crate) fn dir_mtime(&self, dir: usize) -> u64 {
self.dir_mtimes[dir]
}
pub(crate) fn dir_count(&self, dir: usize) -> u32 {
self.dir_counts[dir]
}
pub(crate) fn ignore_len(&self) -> usize {
self.ignore_mtimes.len()
}
pub(crate) fn ignore_path(&self, i: usize) -> &str {
let start = if i == 0 { 0 } else { self.ignore_ends[i - 1] as usize };
&self.ignore_paths[start..self.ignore_ends[i] as usize]
}
pub(crate) fn ignore_mtime(&self, i: usize) -> u64 {
self.ignore_mtimes[i]
}
pub(crate) fn ignore_size(&self, i: usize) -> u64 {
self.ignore_sizes[i]
}
pub(crate) fn ignore_present(&self, i: usize) -> bool {
self.ignore_present[i]
}
pub(crate) fn ignore_external(&self, i: usize) -> bool {
self.ignore_external[i]
}
pub(crate) fn mtime(&self, file: usize) -> u64 {
self.meta[file].mtime
}
pub(crate) fn size(&self, file: usize) -> u64 {
self.meta[file].size
}
/// Rejects a corrupt snapshot before any offsets are used for slicing.
fn valid(&self) -> bool {
let max = u32::MAX as usize;
if self.rels.len() > max || self.text.len() > max || self.dirs.len() > max || self.ignore_paths.len() > max {
return false;
}
if self.rel_ends.len() != self.meta.len() {
return false;
}
let boundary = |text: &str, end: u32| end as usize <= text.len() && text.is_char_boundary(end as usize);
let mut rel_end = 0;
for end in &self.rel_ends {
if *end < rel_end || !boundary(&self.rels, *end) {
return false;
}
rel_end = *end;
}
if rel_end as usize != self.rels.len() {
return false;
}
let mut start = 0;
for end in &self.rel_ends {
let rel = &self.rels[start..*end as usize];
if !crate::data::safe_rel(rel) {
return false;
}
start = *end as usize;
}
let mut sym_end = 0;
for meta in &self.meta {
if meta.sym_end < sym_end || meta.sym_end as usize > self.symbols.len() {
return false;
}
sym_end = meta.sym_end;
}
if sym_end as usize != self.symbols.len() {
return false;
}
if !self.symbols.iter().all(|s| {
let Some(name_end) = (s.name_start as usize).checked_add(s.name_len as usize) else {
return false;
};
let Some(container_end) = (s.container_start as usize).checked_add(s.container_len as usize) else {
return false;
};
name_end <= self.text.len()
&& self.text.is_char_boundary(s.name_start as usize)
&& self.text.is_char_boundary(name_end)
&& (s.container_start == u32::MAX
|| (s.container_start as usize <= self.text.len()
&& container_end <= self.text.len()
&& self.text.is_char_boundary(s.container_start as usize)
&& self.text.is_char_boundary(container_end)))
}) {
return false;
}
if self.dir_ends.len() != self.dir_mtimes.len() || self.dir_counts.len() != self.dir_mtimes.len() {
return false;
}
if self.dir_ends.first() != Some(&0) {
return false;
}
let mut dir_end = 0;
for end in &self.dir_ends {
if *end < dir_end || !boundary(&self.dirs, *end) {
return false;
}
dir_end = *end;
}
if dir_end as usize != self.dirs.len() {
return false;
}
let mut start = 0;
for end in &self.dir_ends {
let dir = &self.dirs[start..*end as usize];
if !dir.is_empty() && !crate::data::safe_rel(dir) {
return false;
}
start = *end as usize;
}
if self.ignore_ends.len() != self.ignore_mtimes.len()
|| self.ignore_sizes.len() != self.ignore_mtimes.len()
|| self.ignore_present.len() != self.ignore_mtimes.len()
|| self.ignore_external.len() != self.ignore_mtimes.len()
{
return false;
}
let mut ignore_end = 0;
for end in &self.ignore_ends {
if *end < ignore_end || !boundary(&self.ignore_paths, *end) {
return false;
}
ignore_end = *end;
}
if ignore_end as usize != self.ignore_paths.len() {
return false;
}
let mut start = 0;
for end in &self.ignore_ends {
if start == *end as usize {
return false;
}
start = *end as usize;
}
true
}
pub(crate) fn rel(&self, file: usize) -> &str {
let start = if file == 0 { 0 } else { self.rel_ends[file - 1] as usize };
&self.rels[start..self.rel_ends[file] as usize]
}
pub(crate) fn entry(&self, file: usize, root: &Path) -> (FileEntry, Vec<StoredSymbol>) {
let meta = self.meta[file];
let sym_start = if file == 0 { 0 } else { self.meta[file - 1].sym_end as usize };
let symbols = (sym_start..meta.sym_end as usize)
.map(|i| {
let s = self.symbols[i];
let name_start = s.name_start as usize;
let name_end = name_start + s.name_len as usize;
let name = &self.text[name_start..name_end];
let container = (s.container_start != u32::MAX).then(|| {
let start = s.container_start as usize;
&self.text[start..start + s.container_len as usize]
});
StoredSymbol {
name: name.to_owned(),
kind: s.kind,
container: container.map(str::to_owned),
range: s.range,
}
})
.collect();
let rel = self.rel(file);
FileEntry::new(
root.join(rel),
rel.to_owned(),
FileRecord { mtime: meta.mtime, size: meta.size, hash: meta.hash, symbols, ignored: meta.ignored },
)
}
}
type Result<T> = std::result::Result<T, String>;
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
fn fit(n: usize) -> Result<u32> {
u32::try_from(n).map_err(|_| "index snapshot exceeds the 4 GiB span limit".to_owned())
}
pub(crate) struct Store {
/// Opened lazily: a valid warm-start snapshot serves start-up without
/// touching redb. Write-locked only for [`Store::compact`].
db: RwLock<Option<Database>>,
/// Columnar warm-start snapshot, kept beside the database.
snapshot: std::path::PathBuf,
/// Recent-file list, readable without opening redb.
recent_file: std::path::PathBuf,
stamp: String,
/// Write-locked only for [`Store::compact`].
db: RwLock<Database>,
}
impl Store {
/// Opens (or creates) the cache directory. The redb database itself is opened
/// lazily so a valid columnar snapshot can serve start-up without touching it.
/// Returns `None` only when the cache directory cannot be created; the index
/// then runs in memory only.
/// Opens (or creates) `cache_dir/index.redb`. Records written by another
/// schema / extractor version or for another root are discarded. Returns
/// `None` when the database cannot be used (e.g. locked by another window);
/// the index then runs in memory only.
pub(crate) fn open(cache_dir: &Path, root: &Path, fingerprint: &str) -> Option<Self> {
if let Err(e) = std::fs::create_dir_all(cache_dir) {
tracing::warn!("index cache {}: {e}", cache_dir.display());
return None;
}
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
Some(Self {
db: RwLock::new(None),
snapshot: cache_dir.join("index.snapshot"),
recent_file: cache_dir.join("recent.snapshot"),
stamp,
})
}
fn open_db(&self) -> Result<Database> {
let file = self.snapshot.with_file_name("index.redb");
let file = cache_dir.join("index.redb");
let db = match Database::create(&file) {
Ok(db) => db,
Err(redb::DatabaseError::DatabaseAlreadyOpen) => {
return Err(format!("{} is in use", file.display()));
tracing::warn!("index cache {} is in use; indexing in memory only", file.display());
return None;
}
Err(e) => {
tracing::warn!("index cache {} unreadable ({e}); rebuilding", file.display());
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_file(&self.snapshot);
Database::create(&file).map_err(err)?
Database::create(&file).map_err(|e| tracing::warn!("index cache: {e}")).ok()?
}
};
let valid = {
let txn = db.begin_read().map_err(err)?;
match txn.open_table(META) {
Ok(table) => table.get("stamp").map_err(err)?.is_some_and(|v| v.value() == self.stamp.as_bytes()),
Err(TableError::TableDoesNotExist(_)) => false,
Err(e) => return Err(err(e)),
let store = Self { db: RwLock::new(db) };
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
if let Err(e) = store.reset(&stamp) {
tracing::warn!("index cache reset failed: {e}");
return None;
}
}
}
Some(store)
}
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
let txn = self.db.read().begin_read().map_err(err)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(err(e)),
};
if !valid {
let _ = std::fs::remove_file(&self.snapshot);
let _ = std::fs::remove_file(&self.recent_file);
Self::reset_db(&db, &self.stamp)?;
}
Ok(db)
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
}
fn ensure_db(&self) -> Result<()> {
if self.db.read().is_some() {
return Ok(());
}
let mut slot = self.db.write();
if slot.is_none() {
*slot = Some(self.open_db()?);
}
Ok(())
}
fn reset_db(db: &Database, stamp: &str) -> Result<()> {
let txn = db.begin_write().map_err(err)?;
fn reset(&self, stamp: &str) -> Result<()> {
let txn = self.db.read().begin_write().map_err(err)?;
match txn.delete_table(FILES) {
Ok(_) | Err(TableError::TableDoesNotExist(_)) => {}
Err(e) => return Err(err(e)),
@@ -415,31 +82,16 @@ impl Store {
txn.commit().map_err(err)
}
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(err(e)),
};
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
}
/// All persisted records; undecodable entries are skipped (and re-indexed).
pub(crate) fn load(&self) -> Result<Vec<(String, FileRecord)>> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
let txn = self.db.read().begin_read().map_err(err)?;
let table = match txn.open_table(FILES) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
Err(e) => return Err(err(e)),
};
let iter = table.iter().map_err(err)?;
let mut out = Vec::with_capacity(iter.size_hint().0);
for item in iter {
let mut out = Vec::new();
for item in table.iter().map_err(err)? {
let (k, v) = item.map_err(err)?;
if let Ok(rec) = postcard::from_bytes::<FileRecord>(v.value()) {
out.push((k.value().to_owned(), rec));
@@ -448,115 +100,10 @@ impl Store {
Ok(out)
}
/// Writes the current index as one columnar snapshot for fast next start-up.
/// Only records persisted below `root` are included; content roots outside it
/// are rescanned, as they are for the per-file records.
pub(crate) fn save_snapshot(&self, data: &IndexData, filter: &PathFilter, not_after: SystemTime) -> Result<()> {
let guard = not_after.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64);
let root = filter.root();
let mut snap = Snapshot { filter: filter.stamp(), stamp: self.stamp.clone(), ..Default::default() };
let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
dirs.insert(String::new());
for dir in filter.dirs(root) {
if let Ok(rel) = dir.strip_prefix(root) {
dirs.insert(rel.to_string_lossy().replace('\\', "/").trim_end_matches('/').to_owned());
}
}
for e in data.files.iter().flatten().filter(|e| e.path.starts_with(root)) {
dirs.insert(e.dir().trim_end_matches('/').to_owned());
snap.rels.push_str(&e.rel);
snap.rel_ends.push(fit(snap.rels.len())?);
for s in data.symbols_of(e) {
let name_start = fit(snap.text.len())?;
snap.text.push_str(s.name);
let (container_start, container_len) = match s.container {
Some(c) => {
let start = fit(snap.text.len())?;
snap.text.push_str(c);
(start, fit(c.len())?)
}
None => (u32::MAX, 0),
};
snap.symbols.push(FlatSymbol {
name_start,
name_len: fit(s.name.len())?,
container_start,
container_len,
kind: s.kind,
range: s.range,
});
}
snap.meta.push(FileMeta {
mtime: e.mtime,
size: e.size,
hash: e.hash,
ignored: e.ignored,
sym_end: fit(snap.symbols.len())?,
});
}
let dir_list: Vec<String> = dirs.into_iter().collect();
for rel in &dir_list {
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
let mtime = std::fs::symlink_metadata(&path).ok().map(|m| mtime_of(&m)).unwrap_or(0);
if mtime > guard {
let _ = std::fs::remove_file(&self.snapshot);
return Ok(());
}
let count = if rel.is_empty() {
0
} else {
let key = format!("{rel}/");
data.dirs.get(key.as_str()).map_or(0, |d| d.1)
};
snap.dirs.push_str(rel);
snap.dir_ends.push(fit(snap.dirs.len())?);
snap.dir_mtimes.push(mtime);
snap.dir_counts.push(count);
}
let external: std::collections::BTreeSet<std::path::PathBuf> =
external_ignore_candidates(root).into_iter().collect();
let mut candidates: std::collections::BTreeSet<std::path::PathBuf> =
per_dir_ignore_candidates(root, &dir_list).into_iter().collect();
candidates.extend(external.iter().cloned());
for path in candidates {
let meta = std::fs::metadata(&path).ok();
let present = meta.is_some();
let mtime = meta.as_ref().map_or(0, mtime_of);
if mtime > guard {
let _ = std::fs::remove_file(&self.snapshot);
return Ok(());
}
snap.ignore_paths.push_str(&path.to_string_lossy());
snap.ignore_ends.push(fit(snap.ignore_paths.len())?);
snap.ignore_mtimes.push(if present { mtime } else { 0 });
snap.ignore_sizes.push(if present { meta.as_ref().map_or(0, |m| m.len()) } else { 0 });
snap.ignore_present.push(present);
snap.ignore_external.push(external.contains(&path));
}
fit(snap.rels.len())?;
fit(snap.text.len())?;
fit(snap.dirs.len())?;
fit(snap.ignore_paths.len())?;
let bytes = postcard::to_allocvec(&snap).map_err(err)?;
let tmp = self.snapshot.with_extension("snapshot.tmp");
std::fs::write(&tmp, &bytes).map_err(err)?;
let _ = std::fs::remove_file(&self.snapshot);
std::fs::rename(&tmp, &self.snapshot).map_err(err)
}
/// The columnar start-up snapshot, if it was written and still decodes.
pub(crate) fn load_snapshot(&self) -> Option<Snapshot> {
let bytes = std::fs::read(&self.snapshot).ok()?;
let snapshot: Snapshot = postcard::from_bytes(&bytes).ok()?;
(snapshot.stamp == self.stamp && snapshot.valid()).then_some(snapshot)
}
/// Applies upserts and deletions in one transaction. Non-durable commits
/// are cheap (no fsync) and become durable with the next durable one.
pub(crate) fn apply(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) -> Result<()> {
self.ensure_db()?;
let db = self.db.read();
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
let mut txn = self.db.read().begin_write().map_err(err)?;
if !durable {
txn.set_durability(Durability::None).map_err(err)?;
}
@@ -573,47 +120,25 @@ impl Store {
table.remove(rel.as_str()).map_err(err)?;
}
}
// Any file change makes the columnar start-up snapshot stale.
let _ = std::fs::remove_file(&self.snapshot);
txn.commit().map_err(err)
}
/// Reclaims free pages in the database file; `true` if anything was compacted.
pub(crate) fn compact(&self) -> Result<bool> {
self.ensure_db()?;
let mut db = self.db.write();
db.as_mut().expect("ensured").compact().map_err(err)
self.db.write().compact().map_err(err)
}
pub(crate) fn load_recent(&self) -> Vec<String> {
if let Some(recent) = self.load_recent_file() {
return recent;
}
self.meta("recent").ok().flatten().and_then(|v| postcard::from_bytes(&v).ok()).unwrap_or_default()
}
/// Recent files without opening redb (the warm-start path).
pub(crate) fn load_recent_file(&self) -> Option<Vec<String>> {
let bytes = std::fs::read(&self.recent_file).ok()?;
postcard::from_bytes(&bytes).ok()
}
pub(crate) fn save_recent(&self, recent: &[String]) -> Result<()> {
self.save_recent_file(recent)?;
let bytes = postcard::to_allocvec(recent).map_err(err)?;
self.set_meta("recent", &bytes)
}
/// Rewrites only the recent sidecar, without opening redb.
pub(crate) fn save_recent_file(&self, recent: &[String]) -> Result<()> {
let bytes = postcard::to_allocvec(recent).map_err(err)?;
std::fs::write(&self.recent_file, &bytes).map_err(err)
}
fn set_meta(&self, key: &str, value: &[u8]) -> Result<()> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
let txn = self.db.read().begin_write().map_err(err)?;
txn.open_table(META).map_err(err)?.insert(key, value).map_err(err)?;
txn.commit().map_err(err)
}
@@ -658,58 +183,4 @@ mod tests {
let s = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
assert!(s.load().unwrap().is_empty());
}
#[test]
fn snapshot_roundtrips_and_is_invalidated_by_changes() {
use crate::data::{FileEntry, IndexData, StoredSymbol};
use ide_core::{Range, SymbolKind};
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
let record = FileRecord {
mtime: 7,
size: 3,
symbols: vec![StoredSymbol {
name: "OrderController".into(),
kind: SymbolKind::Class,
container: Some("Order".into()),
range: Range::default(),
}],
..Default::default()
};
let mut data = IndexData::default();
let (entry, symbols) =
FileEntry::new(std::path::PathBuf::from("/p/src/Order.rs"), "src/Order.rs".into(), record);
data.insert(entry, symbols);
// Content roots outside the project root are rescanned, not snapshotted.
let (entry, symbols) =
FileEntry::new(std::path::PathBuf::from("/elsewhere/x.rs"), "x.rs".into(), FileRecord::default());
data.insert(entry, symbols);
store
.save_snapshot(&data, &PathFilter::new(Path::new("/p"), &Vec::new().into(), &[]), SystemTime::now())
.unwrap();
let snapshot = store.load_snapshot().expect("snapshot");
assert_eq!(snapshot.len(), 1);
assert_eq!(snapshot.rel(0), "src/Order.rs");
let (entry, symbols) = snapshot.entry(0, Path::new("/p"));
assert_eq!(&*entry.rel, "src/Order.rs");
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "OrderController");
assert_eq!(symbols[0].container.as_deref(), Some("Order"));
store.apply(&[], &[], false).unwrap();
assert!(store.load_snapshot().is_none(), "a file change invalidates the snapshot");
}
#[test]
fn snapshot_validation_rejects_bad_paths_and_split_utf8() {
let escaping =
Snapshot { rels: "../x".into(), rel_ends: vec![4], meta: vec![FileMeta::default()], ..Default::default() };
assert!(!escaping.valid());
let split =
Snapshot { rels: "é".into(), rel_ends: vec![1], meta: vec![FileMeta::default()], ..Default::default() };
assert!(!split.valid());
}
}
-82
View File
@@ -772,49 +772,6 @@ fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
#[test]
fn incremental_changes_refresh_the_warm_start_snapshot() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
let idx = f.open();
let changed = f.write("src/a.rs", "fn renamed() {}\n");
idx.update(&[changed], &[]);
assert!(idx.wait_idle(WAIT));
std::thread::sleep(Duration::from_millis(150));
assert!(f.cache.join("index.snapshot").exists(), "a watcher update must regenerate the snapshot");
}
#[test]
fn warm_start_detects_changed_ignore_rules() {
let f = Fixture::new(&[("sub/generated/a.rs", "fn a() {}\n"), (".git/HEAD", "ref: refs/heads/main\n")]);
f.write("sub/.gitignore", "");
let idx = f.open();
assert_eq!(idx.status().files, 2, "generated/a.rs plus .gitignore itself");
drop(idx);
std::thread::sleep(Duration::from_millis(20));
f.write("sub/.gitignore", "generated/\n");
let idx = f.open();
assert_eq!(idx.status().files, 1, "an updated ignore rule invalidates the warm snapshot");
}
#[test]
fn warm_start_detects_a_new_file_in_an_existing_empty_directory() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
std::fs::create_dir(f.root.join("empty")).unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1);
assert!(idx.find_dirs("empty", 10).is_empty());
drop(idx);
let idx = f.open();
assert!(idx.find_dirs("empty", 10).is_empty(), "empty folders are not searchable after warm start");
drop(idx);
f.write("empty/new.rs", "fn new_symbol() {}\n");
let idx = f.open();
assert_eq!(idx.status().files, 2);
assert_eq!(symbols(&idx, "new_symbol", SymbolFilter::All), ["new_symbol"]);
}
#[test]
fn rule_changes_refresh_only_the_affected_index_subtree() {
for (rule, subtree) in [(".git/info/exclude", ""), ("sub/.gitignore", "sub"), ("sub/.ignore", "sub")] {
@@ -877,42 +834,3 @@ fn repository_rule_changes_refresh_members_of_an_unindexed_container() {
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
}
#[test]
fn warm_start_sees_edits_deletions_and_renames_made_while_closed() {
let f = Fixture::new(&[
("src/a.rs", "fn aaaa() {}\n"),
("src/deep/b.rs", "fn bbbb() {}\n"),
("c.rs", "fn cccc() {}\n"),
]);
let idx = f.open();
assert!(idx.wait_idle(WAIT));
drop(idx);
std::thread::sleep(Duration::from_millis(200));
// Warm start of an unchanged tree keeps everything.
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert_eq!(symbols(&idx, "aaaa", SymbolFilter::All), ["aaaa"]);
drop(idx);
std::thread::sleep(Duration::from_millis(20));
// Same size, new content; a deletion; a new file two levels down; a rename.
f.write("src/a.rs", "fn zzzz() {}\n");
std::fs::remove_file(f.root.join("c.rs")).unwrap();
f.write("src/deep/deeper/n.rs", "fn nnnn() {}\n");
std::fs::rename(f.root.join("src/deep/b.rs"), f.root.join("src/deep/r.rs")).unwrap();
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "aaaa", SymbolFilter::All).is_empty(), "stale content");
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
assert!(symbols(&idx, "cccc", SymbolFilter::All).is_empty(), "deleted file");
assert_eq!(symbols(&idx, "nnnn", SymbolFilter::All), ["nnnn"]);
assert_eq!(symbols(&idx, "bbbb", SymbolFilter::All), ["bbbb"]);
assert_eq!(idx.status().files, 3);
drop(idx);
// After the cold rescan, the next warm start agrees.
std::thread::sleep(Duration::from_millis(200));
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert_eq!(idx.status().files, 3);
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
}
+41 -306
View File
@@ -1,8 +1,8 @@
//! The background indexing thread: initial load + full scan, then incremental
//! updates queued by the watcher.
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol, safe_rel};
use crate::filter::{Found, PathFilter, mtime_of};
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{Found, PathFilter};
use crate::store::Store;
use ide_core::{IdeEvent, SharedSink, TaskId};
use ide_language::{LanguageRegistry, Syntax};
@@ -12,16 +12,12 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
/// Files larger than this are listed but not parsed for symbols.
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
const CHUNK: usize = 512;
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
#[cfg(not(test))]
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
#[cfg(test)]
const SNAPSHOT_DELAY: Duration = Duration::from_millis(50);
pub(crate) const MAX_RECENT: usize = 50;
pub(crate) enum Job {
@@ -59,11 +55,6 @@ pub(crate) struct Shared {
queue: Mutex<Queue>,
wake: Condvar,
idle: Condvar,
/// Coalesced post-update snapshot refresh.
snapshot_due: Mutex<Option<Instant>>,
/// Directories whose watcher events still need reconciliation before a
/// refreshed snapshot can be trusted.
pending_dirs: Mutex<HashSet<PathBuf>>,
}
impl Shared {
@@ -88,8 +79,6 @@ impl Shared {
queue: Mutex::default(),
wake: Condvar::new(),
idle: Condvar::new(),
snapshot_due: Mutex::default(),
pending_dirs: Mutex::default(),
}
}
@@ -146,15 +135,8 @@ impl Shared {
q.busy = true;
}
if scan {
// A warm start can trust the snapshot when neither the scope nor any
// directory/file metadata changed. Rebuilding the walk's file list is
// the expensive part; validate the flat manifest first.
if !self.try_fast_start() {
let snapshot_guard = SystemTime::now();
let found = self.walk_roots(None);
self.load_persisted(&found);
self.full_scan(Some(found), snapshot_guard);
}
self.load_persisted();
self.full_scan();
} else {
self.indexing.store(false, Ordering::SeqCst);
}
@@ -165,38 +147,17 @@ impl Shared {
self.idle.notify_all();
}
loop {
let mut save_due = false;
let jobs: Vec<Job> = {
let mut q = self.queue.lock();
loop {
if self.is_cancelled() {
return;
}
if !q.jobs.is_empty() {
break;
}
let due = *self.snapshot_due.lock();
if let Some(due) = due {
if Instant::now() >= due {
save_due = true;
break;
}
self.wake.wait_until(&mut q, due);
} else {
self.wake.wait(&mut q);
}
while q.jobs.is_empty() && !self.is_cancelled() {
self.wake.wait(&mut q);
}
if save_due {
Vec::new()
} else {
q.busy = true;
q.jobs.drain(..).collect()
if self.is_cancelled() {
return;
}
q.busy = true;
q.jobs.drain(..).collect()
};
if save_due {
self.save_snapshot_now();
continue;
}
self.process(jobs);
let mut q = self.queue.lock();
q.busy = false;
@@ -206,114 +167,27 @@ impl Shared {
}
}
/// Warm start: validate the snapshot's flat directory/file manifest and hydrate
/// without walking the tree. A changed scope, symlink, directory mtime, file
/// mtime or file size falls back to the full walk.
fn try_fast_start(&self) -> bool {
let started = Instant::now();
let Some(store) = &self.store else { return false };
let load_started = Instant::now();
let Some(snapshot) = store.load_snapshot() else { return false };
let loaded = load_started.elapsed();
fn load_persisted(&self) {
let Some(store) = &self.store else { return };
let filter = self.filter();
if filter.has_extra_roots() || snapshot.filter() != filter.stamp() {
return false;
}
let root = filter.root();
for i in 0..snapshot.dirs_len() {
let rel = snapshot.dir(i);
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
let Ok(meta) = std::fs::symlink_metadata(&path) else { return false };
if !meta.is_dir() || mtime_of(&meta) != snapshot.dir_mtime(i) {
return false;
}
}
let external = crate::store::external_ignore_candidates(root);
let saved_external: Vec<&str> = (0..snapshot.ignore_len())
.filter(|&i| snapshot.ignore_external(i))
.map(|i| snapshot.ignore_path(i))
.collect();
if external.len() != saved_external.len()
|| external.iter().zip(&saved_external).any(|(path, saved)| path.to_string_lossy() != **saved)
{
return false;
}
for i in 0..snapshot.ignore_len() {
let path = Path::new(snapshot.ignore_path(i));
let meta = std::fs::metadata(path).ok();
let present = meta.is_some();
if present != snapshot.ignore_present(i) {
return false;
}
if let Some(meta) = meta
&& (mtime_of(&meta) != snapshot.ignore_mtime(i) || meta.len() != snapshot.ignore_size(i))
{
return false;
}
}
let build_started = Instant::now();
let entries: Vec<Option<(FileEntry, Vec<StoredSymbol>)>> = (0..snapshot.len())
.into_par_iter()
.map(|i| {
let path = root.join(snapshot.rel(i));
let meta = std::fs::symlink_metadata(&path).ok()?;
if !meta.is_file() || mtime_of(&meta) != snapshot.mtime(i) || meta.len() != snapshot.size(i) {
return None;
let root = filter.root().to_owned();
match store.load() {
Ok(records) => {
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
.into_par_iter()
.filter(|(rel, _)| {
let path = root.join(rel);
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
for (e, symbols) in entries {
d.insert(e, symbols);
}
Some(snapshot.entry(i, root))
})
.collect();
let built = build_started.elapsed();
if entries.iter().any(Option::is_none) {
return false;
}
let validated = entries;
let mut entries = Vec::with_capacity(validated.len());
for entry in validated {
entries.push(entry.expect("validated above"));
}
let dirs: Vec<(Box<str>, PathBuf, u32)> = (0..snapshot.dirs_len())
.filter_map(|i| {
let rel = snapshot.dir(i);
(!rel.is_empty() && snapshot.dir_count(i) > 0).then(|| {
let key = format!("{rel}/");
(key.into_boxed_str(), root.join(rel), snapshot.dir_count(i))
})
})
.collect();
let insert_started = Instant::now();
let mut d = self.data.write();
d.load_bulk(entries, dirs);
drop(d);
tracing::info!(
"index snapshot: warm start in {:?} (load {loaded:?}, validate/build {built:?}, insert {:?})",
started.elapsed(),
insert_started.elapsed()
);
#[cfg(test)]
eprintln!(
"index snapshot: warm start load {loaded:?}, validate/build {built:?}, insert {:?}, total {:?}",
insert_started.elapsed(),
started.elapsed()
);
self.indexing.store(false, Ordering::SeqCst);
self.emit_state();
let recent = store.load_recent_file().unwrap_or_else(|| {
let recent = store.load_recent();
let _ = store.save_recent_file(&recent);
recent
});
let mut r = self.recent.lock();
for p in recent.into_iter().map(|rel| root.join(rel)) {
if !r.contains(&p) {
r.push(p);
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
}
r.truncate(MAX_RECENT);
true
}
fn load_recent(&self, store: &Store, root: &Path) {
let recent: Vec<PathBuf> = store.load_recent().into_iter().map(|rel| root.join(rel)).collect();
let mut r = self.recent.lock();
for p in recent {
@@ -324,72 +198,6 @@ impl Shared {
r.truncate(MAX_RECENT);
}
fn load_persisted(&self, found: &[Found]) {
let Some(store) = &self.store else { return };
let root = self.filter().root().to_owned();
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
let started = Instant::now();
match store.load_snapshot() {
Some(snapshot) => {
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = (0..snapshot.len())
.into_par_iter()
.filter_map(|i| {
let rel = snapshot.rel(i);
if !safe_rel(rel) {
return None;
}
let path = root.join(rel);
seen.contains(path.as_path()).then(|| snapshot.entry(i, &root))
})
.collect();
let mut d = self.data.write();
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
d.reserve(entries.len(), symbols, 0);
for (e, symbols) in entries {
d.insert(e, symbols);
}
tracing::info!(
"index snapshot: hydrated {} files, {} symbols in {:?}",
d.file_count,
d.symbol_count,
started.elapsed()
);
#[cfg(test)]
eprintln!("index snapshot: hydrated {} files in {:?}", d.file_count, started.elapsed());
}
None => match store.load() {
Ok(records) => {
// Fallback for an index that changed since the snapshot: redb
// records are decoded one file at a time and filtered by the walk.
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
.into_par_iter()
.filter(|(rel, _)| {
safe_rel(rel) && {
let path = root.join(rel);
seen.contains(path.as_path())
}
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
d.reserve(entries.len(), symbols, 0);
for (e, symbols) in entries {
d.insert(e, symbols);
}
tracing::info!(
"index cache: hydrated {} files, {} symbols in {:?}",
d.file_count,
d.symbol_count,
started.elapsed()
);
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
},
}
self.load_recent(store, &root);
}
/// A cancelled scan (project closed) still ends its progress task and the indexing state.
fn end_scan(&self, task: TaskId) {
self.indexing.store(false, Ordering::SeqCst);
@@ -404,24 +212,11 @@ impl Shared {
self.emit_state();
}
fn walk_roots(&self, dirs: Option<&[PathBuf]>) -> Vec<Found> {
let filter = self.filter();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
let mut found = Vec::new();
for root in roots {
found.extend(filter.walk(root, &self.cancelled));
}
found
fn full_scan(&self) {
self.scan(None);
}
fn full_scan(&self, found: Option<Vec<Found>>, guard: SystemTime) {
self.scan(None, found, guard);
}
fn scan(&self, dirs: Option<&[PathBuf]>, prewalked: Option<Vec<Found>>, guard: SystemTime) {
fn scan(&self, dirs: Option<&[PathBuf]>) {
let started = Instant::now();
self.indexing.store(true, Ordering::SeqCst);
self.emit_state();
@@ -436,7 +231,14 @@ impl Shared {
});
let filter = self.filter();
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
let mut found = Vec::new();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
for root in roots {
found.extend(filter.walk(root, &self.cancelled));
}
if self.is_cancelled() {
return self.end_scan(task);
}
@@ -483,17 +285,6 @@ impl Shared {
}
}
self.persist(&[], &[], true);
if let Some(store) = &self.store {
let recent: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
let d = self.data.read();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
if let Err(e) = store.save_recent_file(&recent) {
tracing::warn!("index recent snapshot: {e}");
}
}
*self.snapshot_due.lock() = None;
self.indexing.store(false, Ordering::SeqCst);
self.sink.emit(IdeEvent::Task {
id: task,
@@ -556,36 +347,6 @@ impl Shared {
self.persist(&puts, &[], false);
}
fn schedule_snapshot(&self) {
if self.store.is_none() {
return;
}
let mut due = self.snapshot_due.lock();
if due.is_none() {
*due = Some(Instant::now() + SNAPSHOT_DELAY);
}
}
fn save_snapshot_now(&self) {
let dirs: Vec<PathBuf> = self.pending_dirs.lock().drain().collect();
if !dirs.is_empty() {
// Reconcile the directories touched since the last scan before
// recording their mtimes; otherwise a missed watcher event could be
// blessed into the manifest.
self.scan(Some(&dirs), None, SystemTime::now());
return;
}
if let Some(store) = &self.store {
let filter = self.filter();
let guard = SystemTime::now();
let d = self.data.read();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
}
*self.snapshot_due.lock() = None;
}
fn persist(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) {
if let Some(store) = &self.store
&& let Err(e) = store.apply(puts, deletes, durable)
@@ -645,17 +406,6 @@ impl Shared {
Job::Reparse(before) => reparse.push(before),
}
}
{
let mut pending = self.pending_dirs.lock();
for path in changed.iter().chain(removed.iter()) {
let dir = if path.is_dir() {
path.clone()
} else {
path.parent().map_or_else(|| path.clone(), Path::to_owned)
};
pending.insert(dir);
}
}
if !rule_dirs.is_empty() {
{
let mut filter = self.filter.write();
@@ -670,14 +420,14 @@ impl Shared {
}
}
if !rescan {
self.scan(Some(&roots), None, SystemTime::now());
self.scan(Some(&roots));
changed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
removed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
}
}
if rescan {
// The scan also picks up whatever `changed` / `removed` reported.
self.full_scan(None, SystemTime::now());
self.full_scan();
changed.clear();
removed.clear();
}
@@ -772,7 +522,6 @@ impl Shared {
if touched {
self.persist(&[], &[], true);
self.emit_state();
self.schedule_snapshot();
}
}
}
@@ -792,17 +541,3 @@ enum Extracted {
Unchanged,
New(FileRecord),
}
#[cfg(test)]
mod tests {
use crate::data::safe_rel;
#[test]
fn cache_keys_stay_below_the_root() {
assert!(safe_rel("src/main.rs"));
assert!(safe_rel("a/b/c.rs"));
for bad in ["", "/etc/passwd", "../out.rs", "a/../../b.rs", "./a.rs"] {
assert!(!safe_rel(bad), "{bad:?}");
}
}
}
+4 -10
View File
@@ -223,10 +223,6 @@ 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.
@@ -589,10 +585,9 @@ impl Peer {
.expect("spawn jsonrpc notification thread");
}
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
// Reader: parses and dispatches; never runs handler code itself.
{
let inner = inner.clone();
let handler = handler.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let seen_message = AtomicBool::new(false);
@@ -615,7 +610,7 @@ impl Peer {
match msg {
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &*handler, &notif_tx, &body);
dispatch(&inner, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
@@ -788,16 +783,15 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
}
}
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let 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;
-26
View File
@@ -541,29 +541,3 @@ 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"));
}
-26
View File
@@ -40,7 +40,6 @@ const BUILTIN: &[(&str, &str)] = &[
("groovy", include_str!("../../../languages/groovy.toml")),
("haskell", include_str!("../../../languages/haskell.toml")),
("hcl", include_str!("../../../languages/hcl.toml")),
("helm-template", include_str!("../../../languages/helm-template.toml")),
("html", include_str!("../../../languages/html.toml")),
("http", include_str!("../../../languages/http.toml")),
("ignore", include_str!("../../../languages/ignore.toml")),
@@ -549,7 +548,6 @@ mod tests {
"Groovy",
"Haskell",
"Haxe",
"Helm Template",
"HXML",
"HTTP",
"IDL",
@@ -843,30 +841,6 @@ mod tests {
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
assert_eq!(detect("common.gotmpl"), "helm-template");
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
assert_ne!(detect("site/index.tpl"), "helm-template");
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
-4
View File
@@ -391,10 +391,6 @@ 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 {
-71
View File
@@ -1,8 +1,6 @@
//! `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;
@@ -24,53 +22,6 @@ 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('\\', "/")
@@ -144,28 +95,6 @@ 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() {
+1 -19
View File
@@ -5,7 +5,6 @@
use serde::{Deserialize, Serialize};
use std::io;
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
/// The discovery file, relative to the app data directory.
@@ -40,9 +39,6 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
#[serde(default = "loopback")]
pub address: IpAddr,
pub port: u16,
pub token: String,
/// The IDE's process.
@@ -51,10 +47,6 @@ pub struct Discovery {
pub version: String,
}
fn loopback() -> IpAddr {
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
impl Discovery {
pub fn read(path: &Path) -> io::Result<Self> {
let bytes = std::fs::read(path)?;
@@ -111,13 +103,7 @@ mod tests {
fn round_trips_privately_and_only_its_owner_removes_it() {
let tmp = tempfile::tempdir().unwrap();
let path = discovery_path(tmp.path());
let ours = Discovery {
address: "100.92.72.95".parse().unwrap(),
port: 1234,
token: "t".into(),
pid: std::process::id(),
version: "0.1.0".into(),
};
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
ours.write(&path).unwrap();
assert_eq!(Discovery::read(&path).unwrap(), ours);
#[cfg(unix)]
@@ -132,10 +118,6 @@ mod tests {
ours.write(&path).unwrap();
Discovery::remove_if_ours(&path);
assert!(!path.exists());
// The file of an IDE that listened on 127.0.0.1 only has no address.
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
}
#[test]
+6 -16
View File
@@ -2,15 +2,14 @@
//! the IDE's built-in tools.
//!
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
//! clients too) on `127.0.0.1:<random port>` (or another address of
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
//! `Authorization: Bearer <token>` (random, per run); requests from another
//! `Origin` or `Host` are refused (DNS rebinding).
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
//! names a folder; the server picks the open project containing it.
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
//! `pid`, `version`; readable only by the user) while the server runs.
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
//! `version`; readable only by the user) while the server runs.
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
//! persisted client configurations hold no token.
@@ -24,7 +23,7 @@ mod server;
pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::{McpServer, ServerOptions, local_address};
pub use server::{McpServer, ServerOptions};
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
@@ -86,15 +85,9 @@ pub trait ToolRegistry: Send + Sync + 'static {
) -> Result<String, ToolCallError>;
}
/// The URL of the server at `127.0.0.1:<port>` for `project`.
/// The URL of the server at `port` for `project`.
pub fn endpoint_url(port: u16, project: &Path) -> String {
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
}
/// The URL of the server at `<address>:<port>` for `project`.
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
let at = std::net::SocketAddr::new(address, port);
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
}
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
@@ -140,9 +133,6 @@ mod tests {
fn urls_and_commands_name_the_project() {
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
assert_eq!(
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
+20 -81
View File
@@ -9,7 +9,6 @@ use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use std::convert::Infallible;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
@@ -41,8 +40,6 @@ type Body = Either<Full<Bytes>, EventStream>;
struct Shared {
registry: Arc<dyn ToolRegistry>,
token: String,
/// The address listened on (`0.0.0.0` / `::`: every address).
address: IpAddr,
port: u16,
requests: protocol::Requests,
cancel: CancellationToken,
@@ -51,7 +48,6 @@ struct Shared {
/// The running server. Stopping it (or dropping it) closes the port and
/// removes its discovery file.
pub struct McpServer {
address: IpAddr,
port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
@@ -64,10 +60,7 @@ pub struct McpServer {
/// How [`McpServer::start_with`] listens.
#[derive(Clone, Debug, Default)]
pub struct ServerOptions {
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
pub address: Option<IpAddr>,
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
/// `None`: a free port.
pub port: Option<u16>,
@@ -93,20 +86,15 @@ impl McpServer {
runtime: &tokio::runtime::Handle,
options: ServerOptions,
) -> std::io::Result<Self> {
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let bind = |port: u16| {
std::net::TcpListener::bind(SocketAddr::new(address, port))
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
};
let (listener, port_unavailable) = match options.port {
Some(port) => match bind(port) {
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => (listener, None),
Err(e) => {
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
(bind(0)?, Some(format!("port {port}: {e}")))
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
}
},
None => (bind(0)?, None),
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
};
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
@@ -120,7 +108,6 @@ impl McpServer {
};
if let Some(path) = &discovery {
let info = Discovery {
address: local_address(address),
port,
token: token.clone(),
pid: std::process::id(),
@@ -133,19 +120,13 @@ impl McpServer {
let shared = Arc::new(Shared {
registry,
token: token.clone(),
address,
port,
requests: Default::default(),
cancel: cancel.clone(),
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
}
/// The address listened on (`0.0.0.0` / `::`: every address).
pub fn address(&self) -> IpAddr {
self.address
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
}
pub fn port(&self) -> u16 {
@@ -162,24 +143,14 @@ impl McpServer {
&self.token
}
/// The endpoint URL for `project`, at the address a client on this computer connects to.
/// The endpoint URL for `project`.
pub fn url(&self, project: &Path) -> String {
crate::endpoint_url_at(local_address(self.address), self.port, project)
crate::endpoint_url(self.port, project)
}
pub fn stop(self) {}
}
/// The address a client on this computer connects to for a server listening on `address`:
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
pub fn local_address(address: IpAddr) -> IpAddr {
match address {
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
a => a,
}
}
impl Drop for McpServer {
fn drop(&mut self) {
self.cancel.cancel();
@@ -241,7 +212,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
if req.uri().path() != "/mcp" {
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
}
if !local_request(req.headers(), shared.address, shared.port) {
if !local_request(req.headers(), shared.port) {
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
}
if !authorized(req.headers(), &shared.token) {
@@ -372,16 +343,11 @@ impl hyper::body::Body for EventStream {
}
}
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
/// (loopback, the address listened on, or any when listening on every address) or as
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
/// even before the token check.
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
/// No `Origin` other than this server's own, and a loopback `Host`: a web
/// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool {
let ours = |authority: &str| {
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
})
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
};
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
@@ -568,7 +534,7 @@ mod tests {
}
#[test]
fn only_hosts_and_origins_of_this_server_pass() {
fn only_loopback_hosts_and_origins_of_this_server_pass() {
let headers = |pairs: &[(header::HeaderName, &str)]| {
let mut h = HeaderMap::new();
for (k, v) in pairs {
@@ -576,43 +542,16 @@ mod tests {
}
h
};
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
assert!(local_request(&headers(&[]), loopback, 80));
assert!(host("127.0.0.1:80", loopback));
assert!(host("[::1]:80", loopback));
assert!(local_request(&headers(&[]), 80));
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
assert!(local_request(
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
loopback,
80
));
assert!(!host("evil.example:80", loopback));
assert!(!host("127.0.0.1:81", loopback));
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
// Listening on one address: that address too, still never a host name.
let lan: IpAddr = "100.92.72.95".parse().unwrap();
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
// Listening on every address: any IP address, still never a host name.
let every: IpAddr = "0.0.0.0".parse().unwrap();
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
assert!(local_request(
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
every,
80
));
}
#[test]
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
assert_eq!(local_address(ip("::")), ip("::1"));
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
}
#[test]
+2 -3
View File
@@ -148,14 +148,13 @@ async fn post(
)
};
let info = Discovery::read(discovery).map_err(|_| not_running())?;
let at = std::net::SocketAddr::new(info.address, info.port);
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
.await
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
tokio::spawn(conn);
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
.header(header::HOST, at.to_string())
.header(header::HOST, format!("127.0.0.1:{}", info.port))
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
.header("mcp-session-id", scope)
.header(header::CONTENT_TYPE, "application/json")
+1 -32
View File
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
@@ -393,34 +393,3 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
second.port_unavailable()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
let mut s = setup();
s.server.take().unwrap().stop();
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
// Every address: clients here use loopback; other computers name the server by an address of it.
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
let port = s.server().port();
assert_eq!(s.server().address().to_string(), "0.0.0.0");
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
let request = |host: String| {
format!(
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
bearer(&s),
ping.len()
)
};
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
// Not an address of this computer: the server does not start, and says which address.
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
assert!(error.to_string().contains("192.0.2.1"), "{error}");
}
-5
View File
@@ -234,11 +234,6 @@ pub trait HostApi: Send + Sync {
let _ = (path, set, item);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Replace all `(path, icon)` entries of a tree decoration set at once (API 1.8).
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
let _ = (set, items);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Trusted repository history.
fn vcs_log(
&self,
-97
View File
@@ -151,67 +151,6 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
}
}
/// Resolves an already-open document for a background edit without following a
/// symbolic link, junction, or other Windows reparse point. Unlike ordinary
/// project reads, background edits may have originated with another machine:
/// accepting a link would let that machine change an unexpected file when the
/// user later saves the editor buffer.
///
/// The input is deliberately project-relative. Existing path components are
/// examined with `symlink_metadata` from the canonical project root; no
/// canonicalization happens after the walk, so a link is never traversed.
pub fn resolve_background_document_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
if path.is_empty() || Path::new(path).is_absolute() {
return Err(format!("{path}: background document paths must be project-relative"));
}
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let components: Vec<_> = Path::new(path).components().collect();
if components.is_empty() || components.iter().any(|part| !matches!(part, std::path::Component::Normal(_))) {
return Err(format!("{path}: background document paths must not leave the project"));
}
let mut current = root.clone();
for (index, part) in components.iter().enumerate() {
let std::path::Component::Normal(name) = part else { unreachable!() };
if background_control_name(name) {
return Err(format!("{path}: background edits may not target VCS or IDE control files"));
}
current.push(name);
let metadata = std::fs::symlink_metadata(&current).map_err(|_| format!("{path}: no such open document"))?;
if is_link_or_reparse(&metadata) {
return Err(format!("{path}: background edits refuse symbolic links"));
}
if index + 1 < components.len() && !metadata.is_dir() {
return Err(format!("{path}: not a directory"));
}
if index + 1 == components.len() && !metadata.is_file() {
return Err(format!("{path}: not a file"));
}
}
Ok(current)
}
/// [`resolve_background_document_in_root`] over a workspace group's roots.
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
first_root_resolving(roots, path, resolve_background_document_in_root)
}
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
#[cfg(windows)]
let reparse = {
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
};
#[cfg(not(windows))]
let reparse = false;
metadata.file_type().is_symlink() || reparse
}
fn background_control_name(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
}
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
/// without requiring it to exist: `Ok(true)` also for a missing path, so
/// callers distinguish "outside" from "absent". Existing symlinks are
@@ -766,25 +705,6 @@ mod tests {
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
}
#[test]
fn background_document_edits_are_relative_and_exclude_control_directories() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
std::fs::write(root.join(".openidea/state"), "state\n").unwrap();
assert!(resolve_background_document_in_root(&root, "src/main.rs").is_ok());
for path in
["/etc/passwd", "../outside", "src/../.git/hooks/pre-commit", ".git/hooks/pre-commit", ".openidea/state"]
{
assert!(resolve_background_document_in_root(&root, path).is_err(), "{path}");
}
}
#[test]
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
for path in [
@@ -820,23 +740,6 @@ mod tests {
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
}
#[cfg(unix)]
#[test]
fn background_document_edits_refuse_final_and_ancestor_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
symlink(root.join("src/main.rs"), root.join("final-link.rs")).unwrap();
symlink(root.join("src"), root.join("linked-src")).unwrap();
assert!(resolve_background_document_in_root(&root, "final-link.rs").unwrap_err().contains("symbolic links"));
assert!(
resolve_background_document_in_root(&root, "linked-src/main.rs").unwrap_err().contains("symbolic links")
);
}
#[cfg(unix)]
#[test]
fn writable_paths_follow_existing_and_dangling_symlinks() {
-3
View File
@@ -932,9 +932,6 @@ pub struct FileDecoration {
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
/// `[[contributes.fileIcons]]` id for this file or folder (API 1.8 `set-file-icon`).
#[serde(default)]
pub icon: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+3 -44
View File
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
return Ok(Err(wit::Error::Invalid("edits too large".into())));
}
let root = self.table.get(&project)?.root.clone();
let path = match self.resolve_background_document_in_project(&root, &path) {
let path = match self.resolve_in_project(&root, &path) {
Ok(path) => path,
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
};
@@ -936,14 +936,6 @@ impl StoreData {
sandbox::resolve_writable_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
})
}
/// A background document edit is the only plugin operation that changes an
/// editor on behalf of a remote peer. It therefore rejects links and IDE
/// control directories instead of using the normal read resolver.
fn resolve_background_document_in_project(&self, root: &Path, path: &str) -> Result<PathBuf, String> {
sandbox::resolve_background_document_in_root(root, path).or_else(|root_error| {
sandbox::resolve_background_document_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
})
}
/// Whether a `project` resource's path lands inside the resource's root or
/// any member of its workspace group, without requiring it to exist.
fn inside_project(&self, root: &Path, path: &str) -> Result<bool, String> {
@@ -2505,13 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration {
badge: item.badge,
style: item.style,
tooltip: item.tooltip,
note: None,
icon: None,
}),
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
)
.map_err(to_wit))
}
@@ -2529,36 +2515,9 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
fn set_file_icon(&mut self, path: String, set_id: String, icon: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: None, icon: Some(icon) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
fn set_file_icons(&mut self, set_id: String, items: Vec<(String, String)>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
if items.len() > 10_000 {
return Ok(Err(wit::Error::Invalid("at most 10000 file icons per set".into())));
}
let mut resolved = Vec::with_capacity(items.len());
for (path, icon) in items {
match self.decoration_path(&path) {
Ok(real) => resolved.push((real, icon)),
Err(e) => return Ok(Err(e)),
}
}
Ok(self.host.file_icon_decorations(&set_id, resolved).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
Binary file not shown.
@@ -1 +1 @@
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
Binary file not shown.
@@ -1 +1 @@
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
Binary file not shown.
@@ -1 +1 @@
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
Binary file not shown.
@@ -1 +1 @@
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
Binary file not shown.
@@ -1 +1 @@
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
+6 -2
View File
@@ -39,8 +39,12 @@ impl Plugin for Fixture {
hover: None,
after: Some(after.into()),
};
host::set_decorations(path, "fixture", &[item("typo", "typo message"), item("weak", "weak message")])
.map(|()| None)
host::set_decorations(
path,
"fixture",
&[item("typo", "typo message"), item("weak", "weak message")],
)
.map(|()| None)
}
_ => Err(Error::NotFound(id.into())),
}
Binary file not shown.
@@ -1 +1 @@
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
Binary file not shown.
@@ -1 +1 @@
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
Binary file not shown.
@@ -1 +1 @@
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
Binary file not shown.
@@ -1 +1 @@
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
Binary file not shown.
@@ -1 +1 @@
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
Binary file not shown.
@@ -1 +1 @@
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
Binary file not shown.
@@ -1 +1 @@
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
Binary file not shown.
@@ -1 +1 @@
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
Binary file not shown.
@@ -1 +1 @@
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
Binary file not shown.
@@ -1 +1 @@
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
Binary file not shown.
@@ -1 +1 @@
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
@@ -1 +1 @@
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
Binary file not shown.
@@ -1 +1 @@
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
Binary file not shown.
@@ -1 +1 @@
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
Binary file not shown.
@@ -1 +1 @@
d11f9cb1130f3f3aaa89514bf47ec8ece9b2ca0edf1d82c11f80b262325f16be
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
Binary file not shown.
@@ -1 +1 @@
48ea4414469ebd70a89da8a5df7b7bddc4d32f5c50f1759ee3cea14558073c92
67660f5ebbfdd3967fabcc0b5c41f215d4a38d9092b91eb0166f12bccfdffffa
Binary file not shown.
@@ -1 +1 @@
0c02303182be04ed3776fb532fd8b72e6057aa0fdb92b7d235000146fef4120e
09c810ce7ebd820ea21525dc9faea99b13e7665b308cb131510e02b86e763c9f
Binary file not shown.
@@ -1 +1 @@
8162a70e848172663463119e6357984a73e54bc71a99b2845621d0733cf9333e
9b3a210440560e05326947c7d549ce691315839daea2931c6a78720f7b0d9e7c
Binary file not shown.
@@ -1 +1 @@
4524cab0ceac631153a945f6b76f7d40efd3c854740ae22c64580381e9532397
57d8b00b5853ca789fe704f21e4394d1524dd73dfd27ea4a0b34f4b0e4f1a5f8
Binary file not shown.
@@ -1 +1 @@
a92f4def072ca4cc805414516ab4f046fc54bcf11394a5413a27ea151c156174
53e355922819b8034c93070323c7eb2b1505d3bf561dd5de6abb930fe514748c
Binary file not shown.
@@ -1 +1 @@
994fca61bc5a10f0229301406af76c188a9cb2f593db85a403c1ece746e9f7b5
7af929a1adf5d89929d66e78068253ccde9ad5cedee329c2d4db6b7a5e29790e
Binary file not shown.
@@ -1 +1 @@
48eaf97f57cc08c3854a1effa37ebf29defca4782f76c7de1da6ef9a02c23441
511fab979382c5d64054ba4f5fdfc98d50e252dfd5896265ee589336bf8e7d70
Binary file not shown.
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff. Show more