mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 20:13:56 -10:00
Compare commits
87
Commits
fix/i18n-cli
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb20f467c | ||
|
|
4ccff88ccf | ||
|
|
fdf29989ec | ||
|
|
3c3b6a6fd1 | ||
|
|
49bb6b2d76 | ||
|
|
632e564d58 | ||
|
|
08333bcff8 | ||
|
|
ae542b3044 | ||
|
|
4cb98ff162 | ||
|
|
cc8ccea888 | ||
|
|
75344c21ef | ||
|
|
91ac7f417c | ||
|
|
2fd43aa29f | ||
|
|
5b7d258adb | ||
|
|
fd1cebdf98 | ||
|
|
c9362247d0 | ||
|
|
8ab9c688ac | ||
|
|
ba0b3d9c7d | ||
|
|
96f8ee27dd | ||
|
|
5529db667c | ||
|
|
9c55c28d5b | ||
|
|
009773c0d4 | ||
|
|
ba0f4329ea | ||
|
|
4d88d44616 | ||
|
|
5e5d804b89 | ||
|
|
83b9744a36 | ||
|
|
4ab8959bac | ||
|
|
d715643ff2 | ||
|
|
a5214cae38 | ||
|
|
54dd3a167f | ||
|
|
0938a507da | ||
|
|
a05a40e0a3 | ||
|
|
3b978721e3 | ||
|
|
9793857024 | ||
|
|
8fb3b7b084 | ||
|
|
22fe1702be | ||
|
|
ad434af78c | ||
|
|
6d103df5cf | ||
|
|
e2d089a0c4 | ||
|
|
47fa0e6f79 | ||
|
|
921f1f2b2d | ||
|
|
88f73630a6 | ||
|
|
504ffac10a | ||
|
|
5ee558d1a7 | ||
|
|
c09fb7191a | ||
|
|
7ee80ed736 | ||
|
|
353768a15f | ||
|
|
5e969f0bad | ||
|
|
6f60f55a1d | ||
|
|
8e5ac1dc01 | ||
|
|
4fcf4555a1 | ||
|
|
40494f2920 | ||
|
|
c016007a2d | ||
|
|
69ce1ab13d | ||
|
|
fcc8583f36 | ||
|
|
b368f21eb6 | ||
|
|
929866bbc5 | ||
|
|
3c781296d6 | ||
|
|
4aed449dc0 | ||
|
|
55431f3965 | ||
|
|
d4b14a36c7 | ||
|
|
d2c565b19d | ||
|
|
01a60ef164 | ||
|
|
b0b768d25e | ||
|
|
3240bfc423 | ||
|
|
15182507cf | ||
|
|
38bd8fbd16 | ||
|
|
2859ec9106 | ||
|
|
a4b62ad27f | ||
|
|
1392e824bb | ||
|
|
6073f35eb6 | ||
|
|
b02964962b | ||
|
|
9ca8097f3c | ||
|
|
1b08d5de09 | ||
|
|
8faedce175 | ||
|
|
c8bdfa8edd | ||
|
|
4d22632609 | ||
|
|
7f61b7b8ab | ||
|
|
abe30914b5 | ||
|
|
1ae3edd5c8 | ||
|
|
52b8cf5e70 | ||
|
|
258ba189c6 | ||
|
|
f3d0417f4c | ||
|
|
291d1324c5 | ||
|
|
a6e137a6ae | ||
|
|
89f00a29f9 | ||
|
|
834f9e172c |
No files matched your search
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
|
||||
RUFF=0.17.3
|
||||
GOPLS=v0.24.0
|
||||
JDTLS=1.63.0-202610191322
|
||||
KOTLIN_LSP=263.7084.0
|
||||
KOTLIN_LSP=271.2085.0
|
||||
GRADLE=8.14.3
|
||||
INTELEPHENSE=1.18.5
|
||||
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
|
||||
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
|
||||
@@ -144,6 +144,13 @@ 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() };
|
||||
@@ -162,9 +169,7 @@ fn from_file_uri(s: &str) -> String {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
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 }
|
||||
drive_path(&String::from_utf8_lossy(&out)).to_owned()
|
||||
}
|
||||
|
||||
/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix.
|
||||
@@ -222,7 +227,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(path, ln, col));
|
||||
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(drive_path(path), ln, col));
|
||||
}
|
||||
if let Some(m) = kotlin_line(rest) {
|
||||
return Some(m);
|
||||
@@ -456,6 +461,20 @@ 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(
|
||||
|
||||
@@ -291,7 +291,7 @@ pub enum NewTaskApprovals {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
/// Serve the tools on 127.0.0.1 while the IDE runs.
|
||||
/// Serve the tools on [`Self::address`] while the IDE runs.
|
||||
pub enabled: bool,
|
||||
/// Run the tools that change the project or start processes without asking first
|
||||
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
@@ -300,6 +300,9 @@ pub struct McpServerSettings {
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
|
||||
/// address of this computer, or `0.0.0.0` / `::` for every address.
|
||||
pub address: String,
|
||||
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
|
||||
/// port forwards keep working after a restart.
|
||||
pub fixed_port: bool,
|
||||
@@ -312,6 +315,8 @@ pub struct McpServerSettings {
|
||||
|
||||
/// The default of [`McpServerSettings::port`].
|
||||
pub const DEFAULT_MCP_PORT: u16 = 26371;
|
||||
/// The default of [`McpServerSettings::address`]: this computer only.
|
||||
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
@@ -320,6 +325,7 @@ impl Default for McpServerSettings {
|
||||
run_without_confirmation: false,
|
||||
agent_tab_limit: 1,
|
||||
agent_archive_limit: 999,
|
||||
address: DEFAULT_MCP_ADDRESS.to_owned(),
|
||||
fixed_port: true,
|
||||
port: DEFAULT_MCP_PORT,
|
||||
fixed_token: false,
|
||||
@@ -673,6 +679,28 @@ pub struct AppearanceSettings {
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
/// Customize Main Toolbar; `None` = the default toolbar.
|
||||
pub main_toolbar: Option<MainToolbarSettings>,
|
||||
/// Appearance › Background Image: an image shown through the IDE window.
|
||||
pub background_image: BackgroundImageSettings,
|
||||
}
|
||||
|
||||
/// Appearance › Background Image. An empty `path` shows no image.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct BackgroundImageSettings {
|
||||
pub path: String,
|
||||
/// Percent, 0–100.
|
||||
pub opacity: u8,
|
||||
/// `scale` (cover the window) | `plain` (original size) | `tile`.
|
||||
pub fill: String,
|
||||
/// Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`,
|
||||
/// `bottom-left`, `bottom`, `bottom-right`.
|
||||
pub anchor: String,
|
||||
}
|
||||
|
||||
impl Default for BackgroundImageSettings {
|
||||
fn default() -> Self {
|
||||
Self { path: String::new(), opacity: 15, fill: "scale".into(), anchor: "center".into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Main toolbar entries per side, in order: title widget ids (`project`,
|
||||
@@ -743,6 +771,7 @@ impl Default for AppearanceSettings {
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
background_image: BackgroundImageSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1130,6 +1159,9 @@ pub struct NodeSettings {
|
||||
pub run_target: String,
|
||||
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
|
||||
pub package_manager: String,
|
||||
/// Runtime for running JavaScript / TypeScript files: `node` | `bun` | `deno`; empty = from the
|
||||
/// project (`deno.json` → Deno, `bun.lock` → Bun, else Node.js).
|
||||
pub runtime: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use ide_core::process::executable_names;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
|
||||
use serde_json::Value;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -212,6 +213,50 @@ pub fn tools_root() -> Option<PathBuf> {
|
||||
TOOLS_ROOT.read().clone()
|
||||
}
|
||||
|
||||
/// The browser js-debug launches for `ty`, or `None` when the launch starts no browser.
|
||||
fn launched_browser(ty: &str) -> Option<&'static str> {
|
||||
match ty {
|
||||
"pwa-chrome" | "chrome" => Some("chrome"),
|
||||
"pwa-msedge" | "msedge" => Some("msedge"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives a browser launch that names no `userDataDir` a profile of `project`'s own in the
|
||||
/// IDE's local data (`cache/browser-profiles`), so sign-ins and site data are still there in
|
||||
/// the next session. js-debug's own default is one temporary profile for every project.
|
||||
pub(crate) fn set_browser_profile(arguments: &mut Value, project: &Path) {
|
||||
if let Some(local_data) = tools_root().and_then(|tools| tools.parent().map(Path::to_owned)) {
|
||||
set_browser_profile_in(arguments, project, &local_data);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_browser_profile_in(arguments: &mut Value, project: &Path, local_data: &Path) {
|
||||
let Some(obj) = arguments.as_object_mut() else { return };
|
||||
let browser = obj.get("type").and_then(Value::as_str).and_then(launched_browser);
|
||||
let launch = obj.get("request").and_then(Value::as_str) == Some("launch");
|
||||
let (Some(browser), true, false) = (browser, launch, obj.contains_key("userDataDir")) else { return };
|
||||
let dir = local_data.join("cache").join("browser-profiles").join(profile_name(project)).join(browser);
|
||||
obj.insert("userDataDir".into(), dir.to_string_lossy().into_owned().into());
|
||||
}
|
||||
|
||||
/// `<folder name>-<hash of the path>`: readable, and distinct for projects whose folders share a name.
|
||||
fn profile_name(project: &Path) -> String {
|
||||
// FNV-1a: the name has to stay the same across toolchains, which `DefaultHasher` doesn't promise.
|
||||
let hash = project
|
||||
.to_string_lossy()
|
||||
.bytes()
|
||||
.fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
|
||||
let name: String = project
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
||||
.collect();
|
||||
format!("{name}-{hash:016x}")
|
||||
}
|
||||
|
||||
/// Executable directories of managed installs, read at lookup time so tools installed
|
||||
/// after startup are found without a restart: `<tool>/bin`, `<tool>/venv/bin`, npm's `.bin`.
|
||||
fn managed_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
@@ -1040,6 +1085,33 @@ mod tests {
|
||||
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_launch_gets_a_profile_of_its_project() {
|
||||
let local = Path::new("/data/local");
|
||||
let profile = |args: serde_json::Value, project: &str| {
|
||||
let mut args = args;
|
||||
set_browser_profile_in(&mut args, Path::new(project), local);
|
||||
args.get("userDataDir").cloned()
|
||||
};
|
||||
let chrome = serde_json::json!({ "type": "pwa-chrome", "request": "launch" });
|
||||
let dir = profile(chrome.clone(), "/work/shop").unwrap();
|
||||
let dir = Path::new(dir.as_str().unwrap());
|
||||
assert!(dir.starts_with("/data/local/cache/browser-profiles"), "{dir:?}");
|
||||
assert!(dir.ends_with("chrome"), "{dir:?}");
|
||||
let name = dir.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert!(name.starts_with("shop-") && name.len() == "shop-".len() + 16, "{name}");
|
||||
// The same project again: the same profile. Another folder of that name, or Edge: another one.
|
||||
assert_eq!(profile(chrome.clone(), "/work/shop").unwrap(), dir.to_string_lossy().as_ref());
|
||||
assert_ne!(profile(chrome.clone(), "/other/shop").unwrap(), dir.to_string_lossy().as_ref());
|
||||
let edge = profile(serde_json::json!({ "type": "pwa-msedge", "request": "launch" }), "/work/shop").unwrap();
|
||||
assert!(Path::new(edge.as_str().unwrap()).ends_with("msedge"), "{edge}");
|
||||
// A chosen profile (or the default one, `false`) is kept; Node and attach requests get none.
|
||||
let own = serde_json::json!({ "type": "pwa-chrome", "request": "launch", "userDataDir": false });
|
||||
assert_eq!(profile(own, "/work/shop"), Some(false.into()));
|
||||
assert_eq!(profile(serde_json::json!({ "type": "pwa-node", "request": "launch" }), "/work/shop"), None);
|
||||
assert_eq!(profile(serde_json::json!({ "type": "pwa-chrome", "request": "attach" }), "/work/shop"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_conflicts_are_recognised_for_retry() {
|
||||
let in_use = IdeError::unavailable(
|
||||
|
||||
@@ -393,6 +393,16 @@ fn no_session(s: DebugSessionId) -> IdeError {
|
||||
IdeError::not_found(format!("no debug session {}", s.0))
|
||||
}
|
||||
|
||||
/// The name of a child session: the adapter's, or the parent's when it gives none. js-debug
|
||||
/// names a browser page's session after the page the browser starts on, before the URL
|
||||
/// loads (`about:blank`, or nothing in a new profile), which says nothing about what is debugged.
|
||||
fn child_name(configuration: &Value, parent: &str) -> String {
|
||||
match configuration.get("name").and_then(Value::as_str) {
|
||||
Some(name) if !name.trim().is_empty() && name != "about:blank" => name.to_owned(),
|
||||
_ => parent.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top.
|
||||
pub fn launch_arguments(config: &LaunchConfig) -> IdeResult<Value> {
|
||||
// VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch
|
||||
@@ -744,6 +754,9 @@ impl DebugManager {
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
|
||||
let dbg = adapter::find_config(&self.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
|
||||
if config.adapter == "js-debug" {
|
||||
adapter::set_browser_profile(&mut arguments, &cwd);
|
||||
}
|
||||
let mut spec = adapter::resolve(&dbg, cwd.clone());
|
||||
if config.adapter == kotlin::ADAPTER
|
||||
&& let Some(home) = arguments
|
||||
@@ -872,8 +885,7 @@ impl DebugManager {
|
||||
request: String,
|
||||
configuration: Value,
|
||||
) -> IdeResult<()> {
|
||||
let name =
|
||||
configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone());
|
||||
let name = child_name(&configuration, &parent.name);
|
||||
let mut configuration = configuration;
|
||||
if let Some(obj) = configuration.as_object_mut() {
|
||||
obj.entry("request").or_insert_with(|| request.clone().into());
|
||||
|
||||
@@ -905,6 +905,14 @@ async fn blocked_program_input_does_not_hold_up_shutdown() {
|
||||
assert!(h.manager.send_input(session, "later").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
|
||||
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
|
||||
assert_eq!(child_name(&json!({ "name": "about:blank" }), "Shop in Chrome"), "Shop in Chrome");
|
||||
assert_eq!(child_name(&json!({ "name": "" }), "Shop in Chrome"), "Shop in Chrome");
|
||||
assert_eq!(child_name(&json!({}), "Shop in Chrome"), "Shop in Chrome");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
|
||||
+46
-12
@@ -324,6 +324,17 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
|
||||
/// Connection target: a data source id (its configured database), or
|
||||
/// `<id>:<database>` for another database of the same server. Data source ids
|
||||
/// never contain `:`.
|
||||
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
|
||||
fn tunneled(driver: DbDriver) -> bool {
|
||||
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
|
||||
}
|
||||
|
||||
/// The database host and port the SSH host forwards to.
|
||||
fn tunnel_target(source: &DataSource) -> (&str, u16) {
|
||||
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
|
||||
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
|
||||
}
|
||||
|
||||
pub fn split_target(target: &str) -> (&str, Option<&str>) {
|
||||
match target.split_once(':') {
|
||||
Some((id, db)) if !db.is_empty() => (id, Some(db)),
|
||||
@@ -655,9 +666,7 @@ impl DbManager {
|
||||
ssh_secret: Option<String>,
|
||||
shared: bool,
|
||||
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
|
||||
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
|
||||
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
|
||||
return Ok((source.clone(), None));
|
||||
};
|
||||
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
|
||||
@@ -668,8 +677,25 @@ impl DbManager {
|
||||
return Ok((local(&t), Some(t)));
|
||||
}
|
||||
}
|
||||
let session = self.ssh_session(source, ssh, ssh_secret).await?;
|
||||
let (target_host, target_port) = tunnel_target(source);
|
||||
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
|
||||
if shared {
|
||||
self.tunnels.lock().insert(source.id.clone(), t.clone());
|
||||
}
|
||||
Ok((local(&t), Some(t)))
|
||||
}
|
||||
|
||||
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
|
||||
/// opened with the settings typed in the data source.
|
||||
async fn ssh_session(
|
||||
&self,
|
||||
source: &DataSource,
|
||||
ssh: &DbSsh,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<Arc<ide_ssh::Session>> {
|
||||
let config = ssh.config.trim();
|
||||
let session = if config.is_empty() {
|
||||
Ok(if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
@@ -688,14 +714,22 @@ impl DbManager {
|
||||
self.ssh.set_secret(config, Some(s), save);
|
||||
}
|
||||
self.ssh.session(config).await?
|
||||
};
|
||||
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
|
||||
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
|
||||
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
|
||||
if shared {
|
||||
self.tunnels.lock().insert(source.id.clone(), t.clone());
|
||||
}
|
||||
Ok((local(&t), Some(t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
|
||||
/// the database host and port, without logging in to the database.
|
||||
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
|
||||
let source = &source.resolved()?;
|
||||
let ssh = source
|
||||
.ssh
|
||||
.as_ref()
|
||||
.filter(|s| s.enabled && tunneled(source.driver))
|
||||
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
|
||||
let session = self.ssh_session(source, ssh, ssh_secret).await?;
|
||||
let (host, port) = tunnel_target(source);
|
||||
drop(Tunnel::open(session.clone(), host, port).await?);
|
||||
Ok(format!("{} reaches {host}:{port}", session.label()))
|
||||
}
|
||||
|
||||
/// Connects with `source` as given (not necessarily saved): Test Connection.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
|
||||
//! the SSH host reaching the database host and port, with no database login.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
|
||||
use ide_ssh::{SshFiles, SshManager};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
struct Env {
|
||||
_tmp: tempfile::TempDir,
|
||||
db: DbManager,
|
||||
ssh_port: u16,
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
|
||||
let ssh = Arc::new(SshManager::with_files(
|
||||
root.join("ssh-secrets.json"),
|
||||
SshFiles { known_hosts: root.join("known_hosts"), config: None },
|
||||
));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
Env { _tmp: tmp, db, ssh_port }
|
||||
}
|
||||
|
||||
impl Env {
|
||||
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
|
||||
fn source(&self, db_port: u16) -> DataSource {
|
||||
DataSource {
|
||||
id: "pg".into(),
|
||||
name: "pg".into(),
|
||||
driver: DbDriver::Postgres,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(db_port),
|
||||
user: "app".into(),
|
||||
ssh: Some(DbSsh {
|
||||
enabled: true,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(self.ssh_port),
|
||||
user: "dev".into(),
|
||||
auth: DbSshAuth::Password,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A port something listens on (and never speaks a database protocol).
|
||||
async fn listening() -> (TcpListener, u16) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(listener, port)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_tunnel_is_tested_without_a_database_login() {
|
||||
let env = env().await;
|
||||
let (_db, db_port) = listening().await;
|
||||
let source = env.source(db_port);
|
||||
|
||||
// First contact: the host key has to be trusted, as with Test Connection.
|
||||
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
|
||||
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
|
||||
env.db.trust_ssh_host("pg").await.unwrap();
|
||||
|
||||
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
|
||||
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
|
||||
|
||||
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
|
||||
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
|
||||
let env = env().await;
|
||||
let (listener, closed) = listening().await;
|
||||
drop(listener);
|
||||
let source = env.source(closed);
|
||||
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
|
||||
env.db.trust_ssh_host("pg").await.unwrap();
|
||||
|
||||
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
|
||||
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_source_without_a_tunnel_has_nothing_to_test() {
|
||||
let env = env().await;
|
||||
let mut source = env.source(5432);
|
||||
source.ssh.as_mut().unwrap().enabled = false;
|
||||
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
|
||||
}
|
||||
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.7.0";
|
||||
pub const ENGINE_VERSION: &str = "1.8.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
@@ -166,6 +166,7 @@ pub const EVENT_KINDS: &[&str] = &[
|
||||
"editor-focused",
|
||||
"action-performed",
|
||||
"vcs-changed",
|
||||
"problems-changed",
|
||||
];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
@@ -372,6 +373,11 @@ impl Manifest {
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 6)?;
|
||||
}
|
||||
// Added after 1.7.0 shipped: a 1.7 host rejects the manifest, so the gate is the minor
|
||||
// that carries it.
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "problems-changed") {
|
||||
self.needs_api(&format!("`{event}`"), 8)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Host-owned diagnostics snapshot: every `Diagnostics` event lands here, whoever
|
||||
//! emitted it and whatever window it was addressed to. Plugins read this instead of
|
||||
//! the UI-reported Problems reports, so what a plugin sees no longer depends on
|
||||
//! window lifetime, the invoking window's project, or the report loop's liveness.
|
||||
|
||||
use ide_core::{Diagnostic, EventSink, IdeEvent, SharedSink};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DiagnosticsStore(RwLock<BTreeMap<PathBuf, BTreeMap<String, Vec<Diagnostic>>>>);
|
||||
|
||||
impl DiagnosticsStore {
|
||||
/// Full replacement for one (file, producer), like the event itself.
|
||||
pub fn replace(&self, path: &PathBuf, source: &str, items: Vec<Diagnostic>) {
|
||||
let mut map = self.0.write();
|
||||
let sources = map.entry(path.clone()).or_default();
|
||||
if items.is_empty() {
|
||||
sources.remove(source);
|
||||
if sources.is_empty() {
|
||||
map.remove(path);
|
||||
}
|
||||
} else {
|
||||
sources.insert(source.to_owned(), items);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every file's diagnostics as one entry per producer.
|
||||
pub fn problems(&self) -> Vec<(PathBuf, String, Vec<Diagnostic>)> {
|
||||
let map = self.0.read();
|
||||
map.iter()
|
||||
.flat_map(|(path, sources)| {
|
||||
sources.iter().map(move |(source, items)| (path.clone(), source.clone(), items.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &PathBuf) -> BTreeMap<String, Vec<Diagnostic>> {
|
||||
self.0.read().get(path).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Drops the files under `root` (their project closed; the producers will re-report
|
||||
/// on open). Returns the number of files removed.
|
||||
pub fn forget_root(&self, root: &PathBuf) -> usize {
|
||||
let mut map = self.0.write();
|
||||
let before = map.len();
|
||||
map.retain(|path, _| !path.starts_with(root));
|
||||
before - map.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `Diagnostics` events, then delivers them as usual.
|
||||
pub struct RecordingSink {
|
||||
pub inner: SharedSink,
|
||||
pub store: Arc<DiagnosticsStore>,
|
||||
}
|
||||
|
||||
impl EventSink for RecordingSink {
|
||||
fn emit(&self, event: IdeEvent) {
|
||||
if let IdeEvent::Diagnostics { path, source, items } = &event {
|
||||
self.store.replace(&path.0.clone(), source, items.clone());
|
||||
}
|
||||
self.inner.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// A sink whose diagnostics are recorded; the store outlives every project.
|
||||
pub type RecordSinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
|
||||
|
||||
/// Wraps a sink factory so every sink it creates records diagnostics.
|
||||
pub fn recording_sink_factory(store: Arc<DiagnosticsStore>, inner: crate::SinkFactory) -> crate::SinkFactory {
|
||||
Box::new(move |window| {
|
||||
let inner = inner(window);
|
||||
Arc::new(RecordingSink { inner, store: store.clone() }) as SharedSink
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod agent_secrets;
|
||||
pub mod data_dirs;
|
||||
pub mod diagnostics_store;
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
@@ -58,6 +59,9 @@ pub struct HostConfig {
|
||||
pub struct Host {
|
||||
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
/// Every `Diagnostics` event the host ever emitted, per file and producer:
|
||||
/// the snapshot plugins read (`problems`), independent of windows and reports.
|
||||
pub diagnostics: Arc<diagnostics_store::DiagnosticsStore>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
/// Commands agents run in the Run tool window (`run_command`).
|
||||
@@ -135,12 +139,16 @@ impl Host {
|
||||
runtime,
|
||||
project_environment,
|
||||
} = config;
|
||||
// Every sink the host hands out records diagnostics into the shared store first.
|
||||
let diagnostics: Arc<diagnostics_store::DiagnosticsStore> = Arc::default();
|
||||
let sink_for: SinkFactory = diagnostics_store::recording_sink_factory(diagnostics.clone(), sink_for);
|
||||
let global_settings = SettingsStore::load(&config_dir, None);
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
plugin_host: RwLock::default(),
|
||||
mcp_executable: RwLock::default(),
|
||||
diagnostics,
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
mcp_agent_runs: Default::default(),
|
||||
@@ -288,6 +296,9 @@ impl Host {
|
||||
}
|
||||
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
|
||||
project.plugin_diagnostics.close();
|
||||
// The producers will re-report when a project reopens; until then the snapshot
|
||||
// must not answer for files whose project is gone.
|
||||
self.diagnostics.forget_root(&project.root);
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), None);
|
||||
plugins.set_active_file(window, None);
|
||||
|
||||
@@ -42,6 +42,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
test(&c.host, &c.window, source, password, ssh_secret).await
|
||||
});
|
||||
d.add("db_test_tunnel", |c| async move {
|
||||
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
|
||||
test_tunnel(&c.host, &c.window, source, ssh_secret).await
|
||||
});
|
||||
d.add("db_set_ssh_secret", |c| async move {
|
||||
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
|
||||
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
|
||||
@@ -212,6 +216,17 @@ pub async fn test(
|
||||
db.test(&source, password, ssh_secret).await
|
||||
}
|
||||
|
||||
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
|
||||
pub async fn test_tunnel(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
source: DataSource,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.test_tunnel(&source, ssh_secret).await
|
||||
}
|
||||
|
||||
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
|
||||
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
|
||||
@@ -74,9 +74,15 @@ pub async fn lsp_completion(
|
||||
host.end_request(request);
|
||||
// Plugin providers (`[[contributes.providers]]`) add theirs.
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
|
||||
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
|
||||
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if items.is_empty() {
|
||||
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
|
||||
return if covered {
|
||||
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
|
||||
} else {
|
||||
result
|
||||
};
|
||||
}
|
||||
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
|
||||
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
|
||||
@@ -309,9 +315,13 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
|
||||
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
|
||||
// editor to tell.
|
||||
if target == doc
|
||||
&& kind == GotoKind::Definition
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
&& !on_declaration_name(host, window, doc, pos).await
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
// A notebook cell is a language-server document only: no plugin navigates in it.
|
||||
@@ -352,6 +362,30 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
|
||||
definition_targets(result, found, in_string)
|
||||
}
|
||||
|
||||
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
|
||||
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
|
||||
let Ok(project) = host.project(window) else { return false };
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(false);
|
||||
}
|
||||
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
|
||||
Ok(is_declaration_name(&at))
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
|
||||
at.node.field.as_deref() == Some("name")
|
||||
&& at.ancestors.first().is_some_and(|parent| {
|
||||
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
|
||||
})
|
||||
}
|
||||
|
||||
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
|
||||
let Ok(project) = host.project(window) else { return false };
|
||||
blocking(move || {
|
||||
@@ -2136,6 +2170,24 @@ mod tests {
|
||||
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaration_names_are_told_apart_from_uses() {
|
||||
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
|
||||
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
|
||||
let java = ide_core::LanguageId::new("java");
|
||||
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
|
||||
let snap = ide_syntax::Snapshot::text(&java, text);
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let at = |line, col| {
|
||||
let pos = Pos::new(line, col);
|
||||
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
|
||||
};
|
||||
assert!(at(1, 10), "the mapper method's name");
|
||||
assert!(at(0, 12), "the interface's name");
|
||||
assert!(!at(3, 45), "a call of the method");
|
||||
assert!(!at(1, 4), "its return type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_inside_string_literals_are_told_apart() {
|
||||
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
|
||||
|
||||
@@ -47,14 +47,17 @@ pub async fn spell_check(
|
||||
let syntax = host.syntax();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let path = project.documents.snapshot(doc)?.path.0.clone();
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
return Ok((path, Vec::new()));
|
||||
}
|
||||
let regions = if PROSE.contains(&snap.language.as_str()) {
|
||||
ide_spell::prose_regions(&snap.text)
|
||||
} else {
|
||||
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
|
||||
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else {
|
||||
return Ok((path, Vec::new()));
|
||||
};
|
||||
found
|
||||
.into_iter()
|
||||
.map(|r| Region {
|
||||
@@ -72,11 +75,32 @@ pub async fn spell_check(
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
|
||||
let typos =
|
||||
speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
|
||||
Ok((path, typos))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
// Record the typos in the host's diagnostics snapshot so line-end annotations
|
||||
// see them without waiting for a UI report; the editor still draws from its
|
||||
// own call above, unchanged.
|
||||
if let Ok((path, typos)) = &result {
|
||||
let items: Vec<ide_core::Diagnostic> = typos
|
||||
.iter()
|
||||
.map(|t| ide_core::Diagnostic {
|
||||
range: t.range,
|
||||
severity: ide_core::Severity::Info,
|
||||
message: format!("Typo: In word '{}'", t.word),
|
||||
source: Some("Spelling".into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: None,
|
||||
})
|
||||
.collect();
|
||||
host.diagnostics.replace(path, "Spelling", items);
|
||||
}
|
||||
result.map(|(_, typos)| typos)
|
||||
}
|
||||
|
||||
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_sdk::Sdk;
|
||||
use ide_tools::jdk::{JdkReleases, JdkVendor};
|
||||
use ide_tools::{InstallEvent, ToolInfo, Tools};
|
||||
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
@@ -89,6 +89,16 @@ pub async fn tools_install(
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
|
||||
/// Cancel with `cancel_request(request)`.
|
||||
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
|
||||
let tools = tools(host, window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Removes a tool the IDE installed.
|
||||
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
|
||||
let tools = tools(host, window)?;
|
||||
@@ -185,6 +195,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
|
||||
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
|
||||
d.add("jdk_download_releases", |c| async move {
|
||||
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
|
||||
|
||||
@@ -22,6 +22,27 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
|
||||
/// to no repository are ignored, and an empty list is the full status.
|
||||
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
if project.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
let repos = project.repos();
|
||||
if repos.is_empty() {
|
||||
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
|
||||
}
|
||||
for r in repos.all() {
|
||||
r.set_ignored_names(settings.ignored_names());
|
||||
}
|
||||
repos.status_paths(&paths)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
|
||||
@@ -1507,7 +1507,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
d.add("vcs_rename_branch", |c| async move {
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
|
||||
|
||||
@@ -1642,6 +1642,9 @@ impl HostApi for PluginApi {
|
||||
) -> Result<(), HostError> {
|
||||
self.set_decorations(path, set, Vec::new(), item, true)
|
||||
}
|
||||
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
self.set_file_icon_decorations(set, items)
|
||||
}
|
||||
fn vcs_log(
|
||||
&self,
|
||||
root: &Path,
|
||||
@@ -3176,26 +3179,17 @@ impl PluginWorkspace for HostWorkspace {
|
||||
Ok(out)
|
||||
}
|
||||
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<ide_plugin::FileProblems>, HostError> {
|
||||
// The host records every `Diagnostics` event it emits (language servers,
|
||||
// builds, spelling, plugin providers), so this sees a file's problems
|
||||
// whatever window opened it — not just the ones its UI reported.
|
||||
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
|
||||
let root = host.project(client).map_err(failed)?.root.clone();
|
||||
let windows = host.windows_for_root(&root);
|
||||
host.project(client).map_err(failed)?;
|
||||
let mut out = Vec::new();
|
||||
for file in host.mcp_reports.problems(&windows) {
|
||||
if path.is_some_and(|p| p != file.path.0) {
|
||||
for (file, source, diagnostics) in host.diagnostics.problems() {
|
||||
if path.is_some_and(|p| p != file) {
|
||||
continue;
|
||||
}
|
||||
if ide_plugin::resolve_new_in_root(&root, &to_ipc(&file.path.0)).is_err() {
|
||||
continue;
|
||||
}
|
||||
let mut sources: BTreeMap<String, Vec<ide_core::Diagnostic>> = BTreeMap::new();
|
||||
for d in file.items {
|
||||
sources.entry(d.server.clone().or_else(|| d.source.clone()).unwrap_or_default()).or_default().push(d);
|
||||
}
|
||||
out.extend(sources.into_iter().map(|(source, diagnostics)| ide_plugin::FileProblems {
|
||||
path: to_ipc(&file.path.0),
|
||||
source,
|
||||
diagnostics,
|
||||
}));
|
||||
out.push(ide_plugin::FileProblems { path: to_ipc(&file), source, diagnostics });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -3321,6 +3315,8 @@ impl PluginWorkspace for HostWorkspace {
|
||||
path: &Path,
|
||||
items: Vec<ide_core::Diagnostic>,
|
||||
) -> Result<(), HostError> {
|
||||
// Any open project may publish problems for any file: annotations follow the
|
||||
// file, not the window (an editor can show a file outside every project root).
|
||||
let projects = HostWorkspace::projects(self);
|
||||
let project = projects
|
||||
.iter()
|
||||
@@ -3332,7 +3328,8 @@ impl PluginWorkspace for HostWorkspace {
|
||||
.iter()
|
||||
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
|
||||
})
|
||||
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
|
||||
.or_else(|| projects.first())
|
||||
.ok_or_else(|| HostError::NotFound("no project is open".into()))?;
|
||||
project.sink.emit(ide_core::IdeEvent::Diagnostics {
|
||||
path: path.to_path_buf().into(),
|
||||
source: format!("plugin:{source}"),
|
||||
@@ -4130,25 +4127,25 @@ 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()));
|
||||
}
|
||||
let replacement = if let Some(path) = path {
|
||||
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project open".into()))?;
|
||||
let (root, real) = workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter_map(|root| ide_plugin::resolve_new_in_root(&root, &to_ipc(path)).ok().map(|real| (root, real)))
|
||||
.max_by_key(|(root, _)| root.components().count())
|
||||
.ok_or_else(|| HostError::Invalid("path outside open projects".into()))?;
|
||||
// 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);
|
||||
Some(super::DecorationSet {
|
||||
root: root.into(),
|
||||
path: real.into(),
|
||||
path: ide_core::IpcPath(path.to_path_buf()),
|
||||
set_id: set.into(),
|
||||
tree,
|
||||
items,
|
||||
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
|
||||
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -4176,6 +4173,60 @@ impl PluginApi {
|
||||
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decoration_root(&self, path: &Path) -> PathBuf {
|
||||
self.shared
|
||||
.workspace()
|
||||
.map(|w| {
|
||||
w.projects()
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
if set.is_empty() || set.len() > 128 {
|
||||
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
|
||||
}
|
||||
if items.len() > 10_000 {
|
||||
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
|
||||
}
|
||||
let mut by_path = BTreeMap::new();
|
||||
for (path, icon) in items {
|
||||
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
|
||||
by_path.insert(path, icon);
|
||||
}
|
||||
let mut all = self.shared.decorations.lock();
|
||||
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
|
||||
sets.retain(|s| !(s.tree && s.set_id == set));
|
||||
for (path, icon) in by_path {
|
||||
let root = self.decoration_root(&path);
|
||||
sets.push(super::DecorationSet {
|
||||
root: root.into(),
|
||||
path: ide_core::IpcPath(path),
|
||||
set_id: set.into(),
|
||||
tree: true,
|
||||
items: Vec::new(),
|
||||
file: Some(ide_plugin::FileDecoration {
|
||||
badge: None,
|
||||
style: String::new(),
|
||||
tooltip: None,
|
||||
note: None,
|
||||
icon: Some(icon),
|
||||
}),
|
||||
});
|
||||
}
|
||||
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
|
||||
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
|
||||
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
|
||||
}
|
||||
all.insert(self.id().into(), sets.clone());
|
||||
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn decoration_style(style: &str) -> bool {
|
||||
matches!(
|
||||
@@ -4188,6 +4239,8 @@ fn decoration_style(style: &str) -> bool {
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "typo"
|
||||
| "weak"
|
||||
| "underline"
|
||||
| "strikethrough"
|
||||
| "muted"
|
||||
@@ -4308,8 +4361,11 @@ mod workbench_tests {
|
||||
};
|
||||
api.decorations(Some(&path), "set", vec![item("highlight"), item("unknown")]).unwrap();
|
||||
assert_eq!(api.shared.decoration_sets()["test"][0].items.len(), 1);
|
||||
assert!(api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).is_err());
|
||||
// Decorations follow the file: a path outside the open project is accepted too.
|
||||
api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).unwrap();
|
||||
assert_eq!(api.shared.decoration_sets()["test"].len(), 2);
|
||||
api.decorations(None, "set", vec![]).unwrap();
|
||||
assert!(api.shared.decoration_sets()["test"].is_empty());
|
||||
for i in 0..50 {
|
||||
api.decorations(Some(&path), &format!("s{i}"), vec![]).unwrap();
|
||||
}
|
||||
@@ -4324,7 +4380,8 @@ mod workbench_tests {
|
||||
badge: Some("abc".into()),
|
||||
style: "info".into(),
|
||||
tooltip: None,
|
||||
note: None
|
||||
note: None,
|
||||
icon: None,
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
@@ -4334,6 +4391,7 @@ mod workbench_tests {
|
||||
style: String::new(),
|
||||
tooltip: Some("why".into()),
|
||||
note: Some(text.into()),
|
||||
icon: None,
|
||||
};
|
||||
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
|
||||
let notes = |api: &PluginApi| {
|
||||
@@ -4347,9 +4405,44 @@ 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());
|
||||
}
|
||||
#[test]
|
||||
fn decoration_styles_keep_the_severity_palette_the_editor_renders() {
|
||||
// Inspection Lens colours typos and weak warnings with their own styles; the
|
||||
// host must keep them, not silently drop them the way an unknown style is dropped.
|
||||
for style in [
|
||||
"highlight",
|
||||
"search",
|
||||
"added",
|
||||
"modified",
|
||||
"removed",
|
||||
"warning",
|
||||
"error",
|
||||
"info",
|
||||
"typo",
|
||||
"weak",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"muted",
|
||||
] {
|
||||
assert!(decoration_style(style), "{style} should be a known style");
|
||||
}
|
||||
assert!(!decoration_style("unknown"));
|
||||
}
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let out = StdCommand::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
@@ -129,6 +129,7 @@ impl PluginHost {
|
||||
}
|
||||
let path = match event {
|
||||
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
|
||||
WorkbenchEvent::EditorFocused(Some(path)) => Path::new(path),
|
||||
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
|
||||
_ => return Some(event.clone()),
|
||||
};
|
||||
@@ -136,16 +137,25 @@ impl PluginHost {
|
||||
return None;
|
||||
}
|
||||
let workspace = workspace.as_ref()?;
|
||||
let real = ide_plugin::resolve_real(path);
|
||||
// The file's own project, not the invoking window's: annotations follow
|
||||
// the file, so a plugin hears about a file another window has open.
|
||||
let root = match client {
|
||||
Some(client) => workspace.project_of(client),
|
||||
None => {
|
||||
let real = ide_plugin::resolve_real(path);
|
||||
workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.max_by_key(|r| r.components().count())
|
||||
}
|
||||
Some(client) => workspace
|
||||
.project_of(client)
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.or_else(|| {
|
||||
workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.max_by_key(|r| r.components().count())
|
||||
}),
|
||||
None => workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.max_by_key(|r| r.components().count()),
|
||||
}?;
|
||||
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
|
||||
return None;
|
||||
|
||||
@@ -396,15 +396,37 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
|
||||
self.provider_plugins(kind, &doc.language)
|
||||
.into_iter()
|
||||
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
|
||||
}
|
||||
|
||||
pub fn provide_completion(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> Vec<(String, CompletionItem)> {
|
||||
self.feature_documents(doc, Some(at))
|
||||
self.provide_completion_for_editor(doc, at, trigger).1
|
||||
}
|
||||
|
||||
pub(crate) fn provide_completion_for_editor(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> (bool, Vec<(String, CompletionItem)>) {
|
||||
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
|
||||
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
|
||||
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
|
||||
let items = self
|
||||
.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.flat_map(|(doc, bounds)| {
|
||||
if self.has_document_providers(ProviderKind::Completion, &doc) {
|
||||
covered = true;
|
||||
}
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
@@ -419,7 +441,8 @@ impl PluginHost {
|
||||
Some((p, i))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(covered, items)
|
||||
}
|
||||
|
||||
pub fn provide_code_actions(
|
||||
|
||||
@@ -352,6 +352,15 @@ where
|
||||
deserializer.deserialize_map(Properties)
|
||||
}
|
||||
|
||||
/// A sample shown in a setting's own style (the Colour Scheme colours), e.g. the
|
||||
/// message an Inspection Lens severity produces.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub struct SettingExample {
|
||||
pub text: String,
|
||||
/// `error`, `warning`, `weak`, `typo`, `info` or `muted`: which sample style to draw.
|
||||
pub style: String,
|
||||
}
|
||||
|
||||
/// One setting of a plugin.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -371,6 +380,8 @@ pub struct SettingSchema {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub example: Option<SettingExample>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Box<SettingSchema>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub properties: Vec<SettingSchema>,
|
||||
@@ -455,6 +466,20 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
|
||||
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
|
||||
}
|
||||
};
|
||||
let example = match o.get("example") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let e: SettingExample = serde_json::from_value(v.clone())
|
||||
.map_err(|e| format!("setting {key:?}: `example` is {{\"text\", \"style\"}}: {e}"))?;
|
||||
if e.text.is_empty() || e.text.len() > 300 {
|
||||
return Err(format!("setting {key:?}: `example.text` is 1–300 bytes"));
|
||||
}
|
||||
if e.style.len() > 32 || !e.style.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return Err(format!("setting {key:?}: `example.style` is a lowercase style name"));
|
||||
}
|
||||
Some(e)
|
||||
}
|
||||
};
|
||||
let s = SettingSchema {
|
||||
key: key.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
@@ -464,6 +489,7 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
|
||||
options,
|
||||
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
|
||||
group: text("group", MAX_TITLE)?,
|
||||
example,
|
||||
items,
|
||||
properties,
|
||||
};
|
||||
@@ -717,6 +743,7 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct FileIconDecl {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
patterns: Vec<String>,
|
||||
icon: PathBuf,
|
||||
#[serde(default)]
|
||||
@@ -728,13 +755,19 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
|
||||
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
|
||||
}
|
||||
|
||||
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
|
||||
/// whatever icon theme is in use (the user's own file type icons still win).
|
||||
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
|
||||
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
|
||||
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
|
||||
/// icons still win).
|
||||
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
|
||||
validate_item_id(&d.id, "file icon id")?;
|
||||
let what = format!("file icon {:?}", d.id);
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
if d.patterns.is_empty() {
|
||||
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
|
||||
} else {
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
|
||||
@@ -743,8 +776,16 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
|
||||
assets.push((file_icon_asset(&d.id, dark), svg));
|
||||
files.push(path.clone());
|
||||
}
|
||||
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
|
||||
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
|
||||
let revision = blake3::hash(&bytes).to_hex().to_string();
|
||||
let json = serde_json::json!({
|
||||
"id": d.id,
|
||||
"patterns": d.patterns,
|
||||
"dark": d.icon_dark.is_some(),
|
||||
"revision": revision,
|
||||
});
|
||||
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
|
||||
let mut out = UiDecl::new(d.id.clone(), label, json);
|
||||
out.files = files;
|
||||
out.assets = assets;
|
||||
Ok(out)
|
||||
|
||||
@@ -447,6 +447,11 @@ 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);
|
||||
}
|
||||
@@ -864,6 +869,20 @@ 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| {
|
||||
@@ -875,15 +894,7 @@ fn scan_repositories(
|
||||
};
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots(&settings.effective(), root, build_cache));
|
||||
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}"),
|
||||
}
|
||||
Repos::discover_roots(&roots, &skip)
|
||||
}
|
||||
|
||||
impl Project {
|
||||
|
||||
@@ -270,7 +270,8 @@ impl Dispatcher {
|
||||
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
|
||||
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
|
||||
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
|
||||
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
|
||||
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
|
||||
ops::register_all(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
@@ -475,6 +475,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_clone"
|
||||
| "vcs_init"
|
||||
| "vcs_status"
|
||||
| "vcs_status_paths"
|
||||
| "vcs_roots"
|
||||
| "vcs_branch_info"
|
||||
| "vcs_line_hunks"
|
||||
|
||||
@@ -430,3 +430,61 @@ async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window
|
||||
host.shutdown().await;
|
||||
set_editor_providers(None);
|
||||
}
|
||||
|
||||
/// A host with a project open: `problems` and `decorations` need a workspace.
|
||||
fn harness(root: &Path) -> Arc<Host> {
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: root.join("config"),
|
||||
data_dir: root.join("data"),
|
||||
local_data_dir: root.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.open_project("main", root).unwrap();
|
||||
host
|
||||
}
|
||||
|
||||
fn plugins_on(host: &Arc<Host>, root: &Path) -> PluginHost {
|
||||
let plugins = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
|
||||
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(host)))));
|
||||
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
plugins
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_plugin_reads_back_the_diagnostics_the_host_recorded() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = harness(tmp.path());
|
||||
let plugins = plugins_on(&host, tmp.path());
|
||||
let path = tmp.path().join("a.txt");
|
||||
std::fs::write(&path, "text").unwrap();
|
||||
// `publish` goes through the host's recording sink into the snapshot a plugin reads.
|
||||
let invocation = || Invocation { client: Some("main".into()), ..Invocation::default() };
|
||||
plugins.run_command(ID, "publish", Some(path.to_str().unwrap()), invocation()).unwrap();
|
||||
let problems = plugins.run_command(ID, "problems", None, invocation()).unwrap().unwrap();
|
||||
assert!(problems.contains("a.txt"), "problems returned {problems:?}");
|
||||
assert!(problems.ends_with(":1"), "one diagnostic from its producer, got {problems:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_line_end_annotation_takes_the_typo_and_weak_styles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = harness(tmp.path());
|
||||
let plugins = plugins_on(&host, tmp.path());
|
||||
let path = tmp.path().join("a.txt");
|
||||
std::fs::write(&path, "text").unwrap();
|
||||
// `decorate` publishes both styles; the host drops an unknown style silently, so a
|
||||
// rejection here means one of the two is not a known decoration style.
|
||||
plugins
|
||||
.run_command(
|
||||
ID,
|
||||
"decorate",
|
||||
Some(path.to_str().unwrap()),
|
||||
Invocation { client: Some("main".into()), ..Invocation::default() },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -99,6 +99,21 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
|
||||
let path = root.join("a.txt");
|
||||
std::fs::write(&path, "value\nnext\nend").unwrap();
|
||||
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
|
||||
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
|
||||
let toml_path = root.join("a.toml");
|
||||
std::fs::write(&toml_path, "k = 1\n").unwrap();
|
||||
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ide_core::error::ErrorKind::Unavailable
|
||||
);
|
||||
let sql_path = root.join("q.sql");
|
||||
std::fs::write(&sql_path, "SELECT 1;").unwrap();
|
||||
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
|
||||
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
|
||||
.parameters,
|
||||
|
||||
@@ -139,7 +139,7 @@ async fn text_search_uses_overlay_excludes_utf16_and_limits() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn problems_share_project_reports_and_keep_sources_separate() {
|
||||
async fn problems_come_from_the_host_snapshot_whatever_window_published_them() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("a.txt");
|
||||
std::fs::write(&path, "text").unwrap();
|
||||
@@ -153,21 +153,30 @@ async fn problems_share_project_reports_and_keep_sources_separate() {
|
||||
deprecated: false,
|
||||
server: None,
|
||||
};
|
||||
f.host.mcp_reports.report_problems(
|
||||
"second",
|
||||
true,
|
||||
vec![ide_host::mcp::FileProblems {
|
||||
// Producers publish per (file, producer) through the event sink, like the
|
||||
// language servers and the spelling check do.
|
||||
let publish = |host: &Host, source: &str, items: Vec<Diagnostic>| {
|
||||
host.project("main").unwrap().sink.emit(ide_core::IdeEvent::Diagnostics {
|
||||
path: path.clone().into(),
|
||||
items: vec![diagnostic("lsp"), diagnostic("plugin")],
|
||||
}],
|
||||
);
|
||||
source: source.into(),
|
||||
items,
|
||||
});
|
||||
};
|
||||
publish(&f.host, "rust-analyzer", vec![diagnostic("lsp")]);
|
||||
publish(&f.host, "build", vec![diagnostic("plugin")]);
|
||||
// A file outside the window's project is visible too: annotations follow the file.
|
||||
let outside = f.host.diagnostics.problems().len();
|
||||
let workspace = f.workspace();
|
||||
let files = workspace.problems("main", Some(&path)).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
|
||||
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
|
||||
f.host.mcp_reports.forget_window("second");
|
||||
assert!(workspace.problems("main", None).unwrap().is_empty());
|
||||
assert_eq!(outside, 2);
|
||||
// A full replacement per producer clears only that producer.
|
||||
publish(&f.host, "rust-analyzer", vec![]);
|
||||
let files = workspace.problems("main", None).unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].source, "build");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -2554,7 +2554,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
f.install(&official("git-toolbox"));
|
||||
// Focusing an editor wakes it to show the branch (nothing fetched yet).
|
||||
let now = Instant::now();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
|
||||
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
|
||||
f.host.flush_events_at(now + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let item = |host: &PluginHost| {
|
||||
@@ -2992,13 +2993,18 @@ fn official_csv_editor_edits_the_shared_document() {
|
||||
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
|
||||
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
|
||||
|
||||
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
|
||||
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
|
||||
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
|
||||
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
|
||||
|
||||
// Settings: without a header row every record is a row.
|
||||
let mut values = serde_json::Map::new();
|
||||
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
|
||||
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
|
||||
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
|
||||
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
|
||||
|
||||
// Undeclared views are refused before any code runs.
|
||||
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
|
||||
@@ -3754,6 +3760,43 @@ path = "templates/new.draw"
|
||||
assert!(f.host.web_page("web-demo", "draw").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_icons_without_patterns_require_api_1_8_and_project_assets() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("dynamic-icons");
|
||||
std::fs::create_dir_all(pkg.join("icons")).unwrap();
|
||||
std::fs::write(pkg.join("icons/helm-dir.svg"), r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>"#)
|
||||
.unwrap();
|
||||
let write_manifest = |api: &str| {
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"id = "dynamic-icons"
|
||||
name = "Dynamic Icons"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "{api}"
|
||||
|
||||
[[contributes.fileIcons]]
|
||||
id = "helm-dir"
|
||||
icon = "icons/helm-dir.svg"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
write_manifest("^1.7");
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("1.8"), "{err:?}");
|
||||
|
||||
write_manifest("^1.8");
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let assets = f.host.assets("dynamic-icons", "fileIcon/helm-dir/").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("<circle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_templates_fixture_installs_without_write_capabilities() {
|
||||
let f = fixture(Limits::default());
|
||||
|
||||
@@ -367,6 +367,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
|
||||
use ide_core::{Diagnostic, Pos, Range, Severity};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let desktop = Arc::new(
|
||||
@@ -426,42 +427,70 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
"severity": "warning", "message": message, "source": "remote-check", "code": null
|
||||
}]})
|
||||
};
|
||||
let publish = |path: &Path, source: &str, message: Option<&str>| {
|
||||
let items = message
|
||||
.map(|message| Diagnostic {
|
||||
range: Range::new(Pos::new(0, 1), Pos::new(0, 3)),
|
||||
severity: Severity::Warning,
|
||||
message: message.into(),
|
||||
source: Some(source.into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
host.project("main").unwrap().sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.to_path_buf().into(),
|
||||
source: source.into(),
|
||||
items,
|
||||
});
|
||||
};
|
||||
let files = json!([
|
||||
problem(&remote_file, "remote warning"),
|
||||
problem(&other_file, "other warning"),
|
||||
problem(&outside_file, "outside warning")
|
||||
]);
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
// UI reports are not the source of the plugin's host-owned snapshot.
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
publish(&other_file, "remote-check", Some("other warning"));
|
||||
publish(&outside_file, "remote-check", Some("outside warning"));
|
||||
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(problems.contains("other warning"), "{problems}");
|
||||
assert!(!problems.contains("outside warning"), "{problems}");
|
||||
assert!(problems.contains("outside warning"), "{problems}");
|
||||
for path in ["remote.txt", remote_file.to_str().unwrap()] {
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(!problems.contains("other warning"), "{problems}");
|
||||
assert!(!problems.contains("outside warning"), "{problems}");
|
||||
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
||||
}
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
|
||||
.await
|
||||
.unwrap();
|
||||
publish(&remote_file, "second-check", Some("second warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(problems.as_str().unwrap().contains("remote warning"));
|
||||
assert!(problems.as_str().unwrap().contains("second warning"));
|
||||
publish(&remote_file, "remote-check", None);
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(!problems.as_str().unwrap().contains("remote warning"));
|
||||
assert!(problems.as_str().unwrap().contains("second warning"));
|
||||
assert!(problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(
|
||||
&peer,
|
||||
"mcp_report_problems",
|
||||
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
publish(&remote_file, "second-check", Some("replacement warning"));
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(problems.as_str().unwrap().contains("replacement warning"));
|
||||
assert!(!problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
assert!(!problems.as_str().unwrap().contains("second warning"));
|
||||
assert!(problems.as_str().unwrap().contains("other warning"));
|
||||
publish(&remote_file, "second-check", None);
|
||||
publish(&other_file, "remote-check", None);
|
||||
publish(&outside_file, "remote-check", None);
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
|
||||
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
|
||||
@@ -513,11 +542,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
stream.read_to_string(&mut response).await.unwrap();
|
||||
assert!(response.contains("You can close this tab"));
|
||||
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
|
||||
let plugin_host = plugins.host().clone();
|
||||
@@ -537,7 +566,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.as_deref(), Some("[]"));
|
||||
// Disconnect clears desktop reports, not the still-live host project snapshot.
|
||||
let problems = result.as_deref().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(problems.contains("remote-check"), "{problems}");
|
||||
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
||||
plugins.host().shutdown();
|
||||
desktop.shutdown();
|
||||
}
|
||||
|
||||
@@ -341,6 +341,7 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
"db_save_sources",
|
||||
"db_set_password",
|
||||
"db_test",
|
||||
"db_test_tunnel",
|
||||
"db_set_ssh_secret",
|
||||
"db_trust_ssh_host",
|
||||
"db_connected",
|
||||
@@ -481,3 +482,117 @@ async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
|
||||
assert!(rpc::client_commands().contains(&"term_spawn"));
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn renames_a_branch_with_the_clients_argument_names() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("repo");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let out = std::process::Command::new("git").args(args).current_dir(&root).output().unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
std::fs::write(root.join("a.txt"), "a\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]);
|
||||
git(&["branch", "feature"]);
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
invoke(&peer, "open_project", json!({"path": root})).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({"path": root, "trusted": true, "folder": false})).await.unwrap();
|
||||
// The argument names the desktop client sends (`vcsRenameBranch` in src/bindings.ts).
|
||||
invoke(
|
||||
&peer,
|
||||
"vcs_rename_branch",
|
||||
json!({"repo": null, "from": "feature", "to": "renamed", "unsetUpstream": false}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
git(&["branch", "--format=%(refname:short)", "--list"]).lines().collect::<Vec<_>>(),
|
||||
["main", "renamed"]
|
||||
);
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
peer.kill();
|
||||
}
|
||||
|
||||
/// A handler reads its arguments by the names the desktop client sends: `src/bindings.ts`
|
||||
/// passes them in camelCase, and a name the client never sends reads as `null` (an
|
||||
/// `Option` silently `None`, anything else an "invalid type: null" error).
|
||||
#[test]
|
||||
fn handlers_read_the_argument_names_the_client_sends() {
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::Path;
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let bindings = std::fs::read_to_string(repo.join("src/bindings.ts")).unwrap();
|
||||
let mut sent = HashMap::<&str, BTreeSet<&str>>::new();
|
||||
for part in bindings.split("__TAURI_INVOKE(\"").skip(1) {
|
||||
let (command, rest) = part.split_once('"').unwrap();
|
||||
let args = rest.strip_prefix(", {").and_then(|r| r.split_once('}')).map_or("", |(args, _)| args);
|
||||
let names = args.split(',').map(|a| a.split(':').next().unwrap().trim()).filter(|a| !a.is_empty());
|
||||
sent.insert(command, names.collect());
|
||||
}
|
||||
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
sources(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
sources(&repo.join("crates/ide-host/src"), &mut files);
|
||||
let mut checked = 0;
|
||||
let mut wrong = Vec::new();
|
||||
for file in files {
|
||||
let src = std::fs::read_to_string(&file).unwrap();
|
||||
// `.add("name", …)`, also with the name on the next line.
|
||||
let starts: Vec<usize> = src
|
||||
.match_indices(".add(")
|
||||
.map(|(i, m)| i + m.len())
|
||||
.filter_map(|i| src[i..].trim_start().strip_prefix('"').map(|rest| src.len() - rest.len()))
|
||||
.collect();
|
||||
for (n, &start) in starts.iter().enumerate() {
|
||||
let body = &src[start..starts.get(n + 1).copied().unwrap_or(src.len())];
|
||||
let (command, body) = body.split_once('"').unwrap();
|
||||
let Some(sent) = sent.get(command) else { continue };
|
||||
checked += 1;
|
||||
for reader in ["c.arg", "c.sender", "c.channel"] {
|
||||
for (at, _) in body.match_indices(reader) {
|
||||
let rest = &body[at + reader.len()..];
|
||||
let rest = match rest.strip_prefix("::<") {
|
||||
Some(generic) => &generic[generic.find(">(").unwrap() + 1..],
|
||||
None => rest,
|
||||
};
|
||||
let Some(name) = rest.strip_prefix("(\"").and_then(|r| r.split_once('"')).map(|(n, _)| n) else {
|
||||
continue;
|
||||
};
|
||||
if !sent.contains(name) {
|
||||
wrong.push(format!("{command} reads `{name}`; the client sends {sent:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 400, "found only {checked} handlers with client bindings");
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
@@ -172,6 +172,7 @@ async fn restricted_vcs_commands_run_no_program_of_the_repository() {
|
||||
}
|
||||
let calls: Vec<Call> = vec![
|
||||
call!("vcs_status", "", ops::vcs::status(host, w)),
|
||||
call!("vcs_status_paths", "", ops::vcs::status_paths(host, w, vec![file.clone()])),
|
||||
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
|
||||
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
|
||||
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! 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;
|
||||
}
|
||||
@@ -31,3 +31,7 @@ criterion = { version = "0.5", default-features = false }
|
||||
[[bench]]
|
||||
name = "index"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "startup"
|
||||
harness = false
|
||||
@@ -0,0 +1,64 @@
|
||||
//! `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);
|
||||
+767
-77
@@ -1,4 +1,13 @@
|
||||
//! In-memory index: one slot per file, symbols stored with their file.
|
||||
//! In-memory index: one slot per file, with compact query-time tables derived from it.
|
||||
//!
|
||||
//! Queries never walk the fat `FileEntry` structs: `HotFiles`, `HotDirs` and `SymTable` hold
|
||||
//! only the fields a scan needs, packed in dense rows with interned strings, so a query
|
||||
//! streams contiguous memory instead of chasing `Box<[Sym]>` / `Box<str>` pointers.
|
||||
//! They are plain data, updated eagerly by `insert`/`remove` (every mutation needs `&mut
|
||||
//! IndexData`), and all queries run under the `IndexData` read lock: a scan therefore sees
|
||||
//! one consistent snapshot, and the only "invalidation" is the update itself. Space left
|
||||
//! behind by replaced/removed entries is reclaimed by compaction inside `insert`/`remove`
|
||||
//! once it dominates the table, and by `shrink`.
|
||||
|
||||
use ide_core::{Range, SymbolKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -15,6 +24,19 @@ 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() {
|
||||
@@ -33,6 +55,40 @@ pub(crate) fn mask_bit(b: u8) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A substring of an interned text arena.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Span {
|
||||
pub start: u32,
|
||||
pub len: u32,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// A slice of nothing; used for optional fields (`None` containers).
|
||||
pub(crate) const NONE: Span = Span { start: u32::MAX, len: 0 };
|
||||
|
||||
/// Appends `s` to `arena`, returning the span it will occupy.
|
||||
///
|
||||
/// Spans are `u32`-addressed and `NONE` uses `u32::MAX` as its start, so an
|
||||
/// arena that reaches that bound cannot be represented. Fail loudly here
|
||||
/// instead of truncating the offsets: a wrapped span would make [`Self::get`]
|
||||
/// slice unrelated text, or panic far from the actual cause.
|
||||
#[inline]
|
||||
fn of(arena: &str, s: &str) -> Self {
|
||||
let start = u32::try_from(arena.len()).expect("index text arena exceeded the 4 GiB span limit");
|
||||
let len = u32::try_from(s.len()).expect("index text span exceeded the 4 GiB span limit");
|
||||
assert!(
|
||||
start != u32::MAX && start.checked_add(len).is_some(),
|
||||
"index text arena exceeded the 4 GiB span limit"
|
||||
);
|
||||
Self { start, len }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn get(self, arena: &str) -> &str {
|
||||
&arena[self.start as usize..(self.start + self.len) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted per-file record (postcard in redb, keyed by the relative path).
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct FileRecord {
|
||||
@@ -52,12 +108,12 @@ pub(crate) struct StoredSymbol {
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
pub(crate) struct Sym {
|
||||
pub name: Box<str>,
|
||||
/// A symbol resolved out of [`SymTable`].
|
||||
pub(crate) struct SymRef<'a> {
|
||||
pub name: &'a str,
|
||||
pub kind: SymbolKind,
|
||||
pub container: Option<Box<str>>,
|
||||
pub container: Option<&'a str>,
|
||||
pub range: Range,
|
||||
pub mask: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct FileEntry {
|
||||
@@ -73,38 +129,34 @@ pub(crate) struct FileEntry {
|
||||
pub mtime: u64,
|
||||
pub size: u64,
|
||||
pub hash: [u8; 32],
|
||||
pub symbols: Box<[Sym]>,
|
||||
/// Range of this file's symbols in [`IndexData::syms`].
|
||||
pub sym_start: u32,
|
||||
pub sym_len: u32,
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
impl FileEntry {
|
||||
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> Self {
|
||||
/// Splits the record into the entry and its symbols; the symbols are flattened into
|
||||
/// the index-wide [`SymTable`] by [`IndexData::insert`].
|
||||
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> (Self, Vec<StoredSymbol>) {
|
||||
let name_start = rel.rfind('/').map_or(0, |i| i + 1);
|
||||
let name = &rel[name_start..];
|
||||
let symbols = record
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|s| Sym {
|
||||
mask: char_mask(&s.name),
|
||||
name: s.name.into_boxed_str(),
|
||||
kind: s.kind,
|
||||
container: s.container.map(String::into_boxed_str),
|
||||
range: s.range,
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
let FileRecord { mtime, size, hash, symbols, ignored } = record;
|
||||
let entry = Self {
|
||||
name_start_chars: rel[..name_start].chars().count() as u32,
|
||||
name_mask: char_mask(name),
|
||||
rel_mask: char_mask(&rel),
|
||||
name_start: name_start as u32,
|
||||
path,
|
||||
rel: rel.into_boxed_str(),
|
||||
mtime: record.mtime,
|
||||
size: record.size,
|
||||
hash: record.hash,
|
||||
symbols,
|
||||
ignored: record.ignored,
|
||||
}
|
||||
mtime,
|
||||
size,
|
||||
hash,
|
||||
sym_start: 0,
|
||||
sym_len: 0,
|
||||
ignored,
|
||||
};
|
||||
(entry, symbols)
|
||||
}
|
||||
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
@@ -115,38 +167,391 @@ impl FileEntry {
|
||||
&self.rel[..self.name_start as usize]
|
||||
}
|
||||
|
||||
pub(crate) fn record(&self) -> FileRecord {
|
||||
FileRecord {
|
||||
mtime: self.mtime,
|
||||
size: self.size,
|
||||
hash: self.hash,
|
||||
symbols: self
|
||||
.symbols
|
||||
.iter()
|
||||
.map(|s| StoredSymbol {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_deref().map(str::to_owned),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
ignored: self.ignored,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the entry is out of date for `found` on disk.
|
||||
pub(crate) fn stale_for(&self, found: &crate::filter::Found) -> bool {
|
||||
self.mtime != found.mtime || self.size != found.size || self.ignored != found.ignored
|
||||
}
|
||||
}
|
||||
|
||||
/// The hot fields of one file slot, packed for the query scan; no pointers.
|
||||
pub(crate) struct FileRow {
|
||||
/// `rel` in [`HotFiles`]' arena.
|
||||
pub rel: Span,
|
||||
/// Byte offset of the file name inside `rel`.
|
||||
pub name_start: u32,
|
||||
pub sym_start: u32,
|
||||
pub sym_len: u32,
|
||||
/// Union of every symbol's [`char_mask`]: a selective symbol query can skip
|
||||
/// the whole file before touching its symbol range.
|
||||
pub sym_mask: u64,
|
||||
pub rel_mask: u64,
|
||||
pub name_mask: u64,
|
||||
/// Mask of the directory part (`rel[..name_start]`), precomputed for path queries.
|
||||
pub dir_mask: u64,
|
||||
/// `rel` is pure ASCII, so nucleo can match it without a UTF-32 conversion.
|
||||
pub ascii: bool,
|
||||
/// The slot holds a file; free slots keep a row so scans line up with `IndexData::files`.
|
||||
pub live: bool,
|
||||
}
|
||||
|
||||
impl FileRow {
|
||||
#[inline]
|
||||
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
|
||||
self.rel.get(arena)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn name<'a>(&self, arena: &'a str) -> &'a str {
|
||||
&self.rel.get(arena)[self.name_start as usize..]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn dir<'a>(&self, arena: &'a str) -> &'a str {
|
||||
&self.rel.get(arena)[..self.name_start as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// One dense row per file slot (same order and length as `IndexData::files`) plus the
|
||||
/// text arena the rows point into.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HotFiles {
|
||||
pub rows: Vec<FileRow>,
|
||||
text: String,
|
||||
dead_text: usize,
|
||||
}
|
||||
|
||||
impl HotFiles {
|
||||
#[inline]
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn row(&self, slot: usize) -> Option<&FileRow> {
|
||||
self.rows.get(slot).filter(|row| row.live)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn row_of_slot(&self, slot: u32) -> Option<&FileRow> {
|
||||
self.row(slot as usize)
|
||||
}
|
||||
|
||||
/// `rel` of a live slot; candidate slots always are.
|
||||
#[inline]
|
||||
pub(crate) fn rel(&self, slot: u32) -> &str {
|
||||
self.rows[slot as usize].rel(&self.text)
|
||||
}
|
||||
|
||||
fn set(&mut self, slot: u32, row: FileRow) {
|
||||
if self.rows.len() as u32 == slot {
|
||||
self.rows.push(row);
|
||||
} else {
|
||||
self.rows[slot as usize] = row;
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&mut self, rel: &str) -> Span {
|
||||
let span = Span::of(&self.text, rel);
|
||||
self.text.push_str(rel);
|
||||
span
|
||||
}
|
||||
|
||||
/// Updates the row of an existing slot (replace) in place.
|
||||
fn update(&mut self, slot: u32, e: &FileEntry, sym_mask: u64) {
|
||||
let idx = slot as usize;
|
||||
if self.rows[idx].rel.get(&self.text) != &*e.rel {
|
||||
self.dead_text += self.rows[idx].rel.len as usize;
|
||||
let span = self.append(&e.rel);
|
||||
self.rows[idx].rel = span;
|
||||
self.rows[idx].ascii = e.rel.is_ascii();
|
||||
self.rows[idx].dir_mask = char_mask(e.dir());
|
||||
}
|
||||
let row = &mut self.rows[idx];
|
||||
row.name_start = e.name_start;
|
||||
row.name_mask = e.name_mask;
|
||||
row.rel_mask = e.rel_mask;
|
||||
row.sym_start = e.sym_start;
|
||||
row.sym_len = e.sym_len;
|
||||
row.sym_mask = sym_mask;
|
||||
row.live = true;
|
||||
}
|
||||
|
||||
fn remove(&mut self, slot: u32) {
|
||||
let row = &mut self.rows[slot as usize];
|
||||
self.dead_text += row.rel.len as usize;
|
||||
row.rel = Span::NONE;
|
||||
row.sym_start = 0;
|
||||
row.sym_len = 0;
|
||||
row.sym_mask = 0;
|
||||
row.live = false;
|
||||
}
|
||||
|
||||
fn compact(&mut self) {
|
||||
let mut text = String::with_capacity(self.text.len() - self.dead_text);
|
||||
for row in &mut self.rows {
|
||||
if !row.live {
|
||||
continue;
|
||||
}
|
||||
let rel = row.rel.get(&self.text);
|
||||
row.rel = Span::of(&text, rel);
|
||||
text.push_str(rel);
|
||||
}
|
||||
self.text = text;
|
||||
self.dead_text = 0;
|
||||
}
|
||||
|
||||
fn shrink(&mut self) {
|
||||
self.compact();
|
||||
self.rows.shrink_to_fit();
|
||||
self.text.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
/// One folder holding indexed files, packed for the query scan.
|
||||
pub(crate) struct DirRow {
|
||||
/// `rel` in [`HotDirs`]' arena, including the trailing `/`.
|
||||
pub rel: Span,
|
||||
/// Byte offset of the last path segment inside the trimmed `rel`.
|
||||
pub name_start: u32,
|
||||
pub name_mask: u64,
|
||||
/// Mask of `rel` without the trailing `/`.
|
||||
pub rel_mask: u64,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl DirRow {
|
||||
#[inline]
|
||||
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
|
||||
self.rel.get(arena)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HotDirs {
|
||||
pub rows: Vec<DirRow>,
|
||||
text: String,
|
||||
dead: usize,
|
||||
}
|
||||
|
||||
impl HotDirs {
|
||||
#[inline]
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
fn push(&mut self, rel: &str, abs: &Path) -> u32 {
|
||||
let span = Span::of(&self.text, rel);
|
||||
self.text.push_str(rel);
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let name_start = trimmed.rfind('/').map_or(0, |i| i + 1);
|
||||
self.rows.push(DirRow {
|
||||
rel: span,
|
||||
name_start: name_start as u32,
|
||||
name_mask: char_mask(&trimmed[name_start..]),
|
||||
rel_mask: char_mask(trimmed),
|
||||
path: abs.to_owned(),
|
||||
});
|
||||
(self.rows.len() - 1) as u32
|
||||
}
|
||||
|
||||
fn remove(&mut self, row: u32, dirs: &mut HashMap<Box<str>, (PathBuf, u32, u32)>) {
|
||||
let idx = row as usize;
|
||||
let last = self.rows.len() - 1;
|
||||
self.dead += self.rows[idx].rel.len as usize;
|
||||
self.rows.swap_remove(idx);
|
||||
if idx != last {
|
||||
let moved = self.rows[idx].rel.get(&self.text);
|
||||
if let Some(dir) = dirs.get_mut(moved) {
|
||||
dir.2 = idx as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compact(&mut self) {
|
||||
let mut text = String::with_capacity(self.text.len() - self.dead);
|
||||
for row in &mut self.rows {
|
||||
let rel = row.rel.get(&self.text);
|
||||
row.rel = Span::of(&text, rel);
|
||||
text.push_str(rel);
|
||||
}
|
||||
self.text = text;
|
||||
self.dead = 0;
|
||||
}
|
||||
|
||||
fn shrink(&mut self) {
|
||||
self.compact();
|
||||
self.rows.shrink_to_fit();
|
||||
self.text.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
/// The scan-relevant fields of one symbol: its character mask and the name in the
|
||||
/// arena. Interleaved so a scan touches one cache line per candidate instead of three
|
||||
/// parallel arrays.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SymHead {
|
||||
pub mask: u64,
|
||||
pub name: Span,
|
||||
}
|
||||
|
||||
/// Index-wide symbol storage: one row per symbol in parallel arrays, with names and
|
||||
/// containers interned in a single arena. Each file points at a contiguous range.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SymTable {
|
||||
head: Vec<SymHead>,
|
||||
container: Vec<Span>,
|
||||
kind: Vec<SymbolKind>,
|
||||
range: Vec<Range>,
|
||||
text: String,
|
||||
dead_rows: usize,
|
||||
}
|
||||
|
||||
impl SymTable {
|
||||
#[inline]
|
||||
pub(crate) fn name(&self, i: u32) -> &str {
|
||||
self.head[i as usize].name.get(&self.text)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn mask(&self, i: u32) -> u64 {
|
||||
self.head[i as usize].mask
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn container(&self, i: u32) -> Option<&str> {
|
||||
let span = self.container[i as usize];
|
||||
(span.start != u32::MAX).then(|| span.get(&self.text))
|
||||
}
|
||||
|
||||
/// The mask + name rows of a file's contiguous symbol range.
|
||||
#[inline]
|
||||
pub(crate) fn heads(&self, start: u32, len: u32) -> &[SymHead] {
|
||||
let (start, end) = (start as usize, start as usize + len as usize);
|
||||
&self.head[start..end]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn kind(&self, i: u32) -> SymbolKind {
|
||||
self.kind[i as usize]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn range(&self, i: u32) -> Range {
|
||||
self.range[i as usize]
|
||||
}
|
||||
|
||||
fn intern(&mut self, s: &str) -> Span {
|
||||
let span = Span::of(&self.text, s);
|
||||
self.text.push_str(s);
|
||||
span
|
||||
}
|
||||
|
||||
/// Appends a file's symbols; they stay contiguous. Returns the start index
|
||||
/// and the union of their masks.
|
||||
fn push(&mut self, symbols: &[StoredSymbol]) -> (u32, u64) {
|
||||
let start = self.head.len() as u32;
|
||||
let mut agg_mask = 0u64;
|
||||
for s in symbols {
|
||||
let mask = char_mask(&s.name);
|
||||
agg_mask |= mask;
|
||||
let name = self.intern(&s.name);
|
||||
let container = match &s.container {
|
||||
Some(c) => self.intern(c),
|
||||
None => Span::NONE,
|
||||
};
|
||||
self.head.push(SymHead { mask, name });
|
||||
self.container.push(container);
|
||||
self.kind.push(s.kind);
|
||||
self.range.push(s.range);
|
||||
}
|
||||
(start, agg_mask)
|
||||
}
|
||||
|
||||
/// Whether `symbols` would flatten to exactly the range at `start`.
|
||||
fn same(&self, start: u32, len: u32, symbols: &[StoredSymbol]) -> bool {
|
||||
len as usize == symbols.len()
|
||||
&& symbols.iter().enumerate().all(|(k, s)| {
|
||||
let i = start + k as u32;
|
||||
self.name(i) == s.name
|
||||
&& self.container(i) == s.container.as_deref()
|
||||
&& self.kind(i) == s.kind
|
||||
&& self.range(i) == s.range
|
||||
})
|
||||
}
|
||||
|
||||
fn free(&mut self, _start: u32, len: u32) {
|
||||
self.dead_rows += len as usize;
|
||||
}
|
||||
|
||||
/// Rewrites the live rows in file order, reclaiming the space of replaced files.
|
||||
/// `files` entries keep their range in sync; queries read rows, but persistence
|
||||
/// and `FileEntry` accessors go through the entry.
|
||||
fn compact(&mut self, rows: &mut [FileRow], files: &mut [Option<FileEntry>]) {
|
||||
let mut text = String::with_capacity(self.text.len());
|
||||
let mut head = Vec::with_capacity(self.head.len() - self.dead_rows);
|
||||
let mut container = Vec::with_capacity(head.capacity());
|
||||
let mut kind = Vec::with_capacity(head.capacity());
|
||||
let mut range = Vec::with_capacity(head.capacity());
|
||||
let mut live = 0u32;
|
||||
for (slot, row) in rows.iter_mut().enumerate() {
|
||||
if !row.live {
|
||||
row.sym_start = 0;
|
||||
continue;
|
||||
}
|
||||
let (start, end) = (row.sym_start, row.sym_start + row.sym_len);
|
||||
row.sym_start = live;
|
||||
if let Some(e) = files.get_mut(slot).and_then(Option::as_mut) {
|
||||
e.sym_start = live;
|
||||
}
|
||||
for i in start..end {
|
||||
let name_span = self.head[i as usize].name;
|
||||
let name = name_span.get(&self.text);
|
||||
head.push(SymHead { mask: self.head[i as usize].mask, name: Span::of(&text, name) });
|
||||
text.push_str(name);
|
||||
let container_span = self.container[i as usize];
|
||||
if container_span.start == u32::MAX {
|
||||
container.push(Span::NONE);
|
||||
} else {
|
||||
let c = container_span.get(&self.text);
|
||||
container.push(Span::of(&text, c));
|
||||
text.push_str(c);
|
||||
}
|
||||
kind.push(self.kind[i as usize]);
|
||||
range.push(self.range[i as usize]);
|
||||
live += 1;
|
||||
}
|
||||
}
|
||||
self.head = head;
|
||||
self.container = container;
|
||||
self.kind = kind;
|
||||
self.range = range;
|
||||
self.text = text;
|
||||
self.dead_rows = 0;
|
||||
}
|
||||
|
||||
fn shrink(&mut self) {
|
||||
self.head.shrink_to_fit();
|
||||
self.container.shrink_to_fit();
|
||||
self.kind.shrink_to_fit();
|
||||
self.range.shrink_to_fit();
|
||||
self.text.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct IndexData {
|
||||
pub files: Vec<Option<FileEntry>>,
|
||||
by_path: HashMap<PathBuf, u32>,
|
||||
/// Every folder holding an indexed file at any depth, keyed by its `rel` with a
|
||||
/// trailing `/`: its absolute path and the number of files below it.
|
||||
pub dirs: HashMap<Box<str>, (PathBuf, u32)>,
|
||||
/// trailing `/`: its absolute path, the number of files below it, and its row in
|
||||
/// [`HotDirs`].
|
||||
pub dirs: HashMap<Box<str>, (PathBuf, u32, u32)>,
|
||||
/// Compact query-time view of `files`, kept in sync by `insert`/`remove`.
|
||||
pub hot: HotFiles,
|
||||
/// Compact query-time view of `dirs`, kept in sync by `insert`/`remove`.
|
||||
pub dirs_hot: HotDirs,
|
||||
/// Flattened symbols of every file, kept in sync by `insert`/`remove`.
|
||||
pub syms: SymTable,
|
||||
free: Vec<u32>,
|
||||
pub file_count: u32,
|
||||
pub symbol_count: u32,
|
||||
@@ -161,49 +566,185 @@ impl IndexData {
|
||||
self.by_path.get(path).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&mut self, entry: FileEntry) {
|
||||
let symbols = entry.symbols.len() as u32;
|
||||
/// 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 {
|
||||
mtime: e.mtime,
|
||||
size: e.size,
|
||||
hash: e.hash,
|
||||
symbols: self
|
||||
.symbols_of(e)
|
||||
.map(|s| StoredSymbol {
|
||||
name: s.name.to_owned(),
|
||||
kind: s.kind,
|
||||
container: s.container.map(str::to_owned),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
ignored: e.ignored,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_of(&self, path: &Path) -> Option<FileRecord> {
|
||||
self.get(path).map(|e| self.record(e))
|
||||
}
|
||||
|
||||
/// The symbols of one file, in declaration order.
|
||||
pub(crate) fn symbols_of<'a>(&'a self, e: &'a FileEntry) -> impl Iterator<Item = SymRef<'a>> + 'a {
|
||||
let syms = &self.syms;
|
||||
(e.sym_start..e.sym_start + e.sym_len).map(move |i| SymRef {
|
||||
name: syms.name(i),
|
||||
kind: syms.kind(i),
|
||||
container: syms.container(i),
|
||||
range: syms.range(i),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&mut self, mut entry: FileEntry, symbols: Vec<StoredSymbol>) {
|
||||
let sym_len = symbols.len() as u32;
|
||||
if let Some(&slot) = self.by_path.get(&entry.path) {
|
||||
let old = self.files[slot as usize].replace(entry);
|
||||
self.symbol_count -= old.map_or(0, |o| o.symbols.len() as u32);
|
||||
self.symbol_count += symbols;
|
||||
let old = self.files[slot as usize].as_ref().expect("indexed slots are live");
|
||||
let old_len = old.sym_len;
|
||||
// Rescans and saves that re-parse to the same symbols reuse the range; a
|
||||
// same-path replacement then costs nothing in the symbol table.
|
||||
let (sym_start, sym_mask) = if self.syms.same(old.sym_start, old_len, &symbols) {
|
||||
(old.sym_start, self.hot.rows[slot as usize].sym_mask)
|
||||
} else {
|
||||
self.syms.free(old.sym_start, old_len);
|
||||
self.syms.push(&symbols)
|
||||
};
|
||||
entry.sym_start = sym_start;
|
||||
entry.sym_len = sym_len;
|
||||
self.symbol_count = self.symbol_count - old_len + sym_len;
|
||||
self.hot.update(slot, &entry, sym_mask);
|
||||
self.files[slot as usize] = Some(entry);
|
||||
self.compact_if_needed();
|
||||
return;
|
||||
}
|
||||
let (sym_start, sym_mask) = self.syms.push(&symbols);
|
||||
entry.sym_start = sym_start;
|
||||
entry.sym_len = sym_len;
|
||||
let slot = match self.free.pop() {
|
||||
Some(slot) => {
|
||||
self.files[slot as usize] = Some(entry);
|
||||
slot
|
||||
}
|
||||
Some(slot) => slot,
|
||||
None => {
|
||||
self.files.push(Some(entry));
|
||||
self.files.push(None);
|
||||
(self.files.len() - 1) as u32
|
||||
}
|
||||
};
|
||||
let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default();
|
||||
let path = entry.path.clone();
|
||||
let (name_start, name_mask, rel_mask) = (entry.name_start, entry.name_mask, entry.rel_mask);
|
||||
let rel = self.hot.append(&entry.rel);
|
||||
self.hot.set(
|
||||
slot,
|
||||
FileRow {
|
||||
rel,
|
||||
name_start,
|
||||
sym_start: entry.sym_start,
|
||||
sym_len,
|
||||
sym_mask,
|
||||
rel_mask,
|
||||
name_mask,
|
||||
dir_mask: char_mask(&entry.rel[..name_start as usize]),
|
||||
ascii: entry.rel.is_ascii(),
|
||||
live: true,
|
||||
},
|
||||
);
|
||||
self.files[slot as usize] = Some(entry);
|
||||
if let Some(e) = &self.files[slot as usize] {
|
||||
for (rel, abs) in ancestors(e) {
|
||||
self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1;
|
||||
if let Some(dir) = self.dirs.get_mut(rel) {
|
||||
dir.1 += 1;
|
||||
} else {
|
||||
let row = self.dirs_hot.push(rel, abs);
|
||||
self.dirs.insert(rel.into(), (abs.to_owned(), 1, row));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.by_path.insert(path, slot);
|
||||
self.file_count += 1;
|
||||
self.symbol_count += symbols;
|
||||
self.symbol_count += sym_len;
|
||||
self.compact_if_needed();
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&mut self, path: &Path) -> Option<FileEntry> {
|
||||
let slot = self.by_path.remove(path)?;
|
||||
let old = self.files[slot as usize].take()?;
|
||||
self.free.push(slot);
|
||||
self.hot.remove(slot);
|
||||
self.syms.free(old.sym_start, old.sym_len);
|
||||
for (rel, _) in ancestors(&old) {
|
||||
if let Some(d) = self.dirs.get_mut(rel) {
|
||||
d.1 -= 1;
|
||||
if d.1 == 0 {
|
||||
self.dirs.remove(rel);
|
||||
}
|
||||
let Some(dir) = self.dirs.get_mut(rel) else { continue };
|
||||
dir.1 -= 1;
|
||||
if dir.1 > 0 {
|
||||
continue;
|
||||
}
|
||||
let Some((_, _, row)) = self.dirs.remove(rel) else { continue };
|
||||
self.dirs_hot.remove(row, &mut self.dirs);
|
||||
}
|
||||
self.file_count -= 1;
|
||||
self.symbol_count -= old.symbols.len() as u32;
|
||||
self.symbol_count -= old.sym_len;
|
||||
self.compact_if_needed();
|
||||
Some(old)
|
||||
}
|
||||
|
||||
@@ -213,6 +754,19 @@ impl IndexData {
|
||||
doomed.into_iter().filter(|p| self.remove(p).is_some()).collect()
|
||||
}
|
||||
|
||||
/// Reclaims the space of replaced/removed files once it dominates a table.
|
||||
fn compact_if_needed(&mut self) {
|
||||
if self.hot.dead_text > 1 << 16 && self.hot.dead_text * 2 > self.hot.text.len() {
|
||||
self.hot.compact();
|
||||
}
|
||||
if self.syms.dead_rows > 4096 && self.syms.dead_rows * 2 > self.syms.head.len() {
|
||||
self.syms.compact(&mut self.hot.rows, &mut self.files);
|
||||
}
|
||||
if self.dirs_hot.dead > 1 << 16 && self.dirs_hot.dead * 2 > self.dirs_hot.text.len() {
|
||||
self.dirs_hot.compact();
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases spare capacity (after large deletions).
|
||||
pub(crate) fn shrink(&mut self) {
|
||||
while matches!(self.files.last(), Some(None)) {
|
||||
@@ -221,6 +775,11 @@ impl IndexData {
|
||||
self.free.retain(|&s| s != last);
|
||||
}
|
||||
self.files.shrink_to_fit();
|
||||
self.hot.rows.truncate(self.files.len());
|
||||
self.hot.shrink();
|
||||
self.dirs_hot.shrink();
|
||||
self.syms.compact(&mut self.hot.rows, &mut self.files);
|
||||
self.syms.shrink();
|
||||
self.free.shrink_to_fit();
|
||||
self.by_path.shrink_to_fit();
|
||||
self.dirs.shrink_to_fit();
|
||||
@@ -248,7 +807,7 @@ fn ancestors(e: &FileEntry) -> impl Iterator<Item = (&str, &Path)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(path: &str, symbols: usize) -> FileEntry {
|
||||
fn entry(path: &str, symbols: usize) -> (FileEntry, Vec<StoredSymbol>) {
|
||||
let record = FileRecord {
|
||||
symbols: (0..symbols)
|
||||
.map(|i| StoredSymbol {
|
||||
@@ -263,48 +822,118 @@ mod tests {
|
||||
FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record)
|
||||
}
|
||||
|
||||
fn insert(d: &mut IndexData, path: &str, symbols: usize) {
|
||||
let (e, s) = entry(path, symbols);
|
||||
d.insert(e, s);
|
||||
}
|
||||
|
||||
/// The compact tables must always mirror the entries they were derived from.
|
||||
fn assert_consistent(d: &IndexData) {
|
||||
assert_eq!(d.hot.rows.len(), d.files.len(), "one hot row per slot");
|
||||
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), d.file_count as usize, "live rows are files");
|
||||
for (slot, e) in d.files.iter().enumerate() {
|
||||
let Some(e) = e else {
|
||||
assert!(d.hot.row(slot).is_none(), "free slot {slot} has no row");
|
||||
continue;
|
||||
};
|
||||
let row = d.hot.row(slot).unwrap_or_else(|| panic!("slot {slot} has a row"));
|
||||
assert_eq!(row.rel(d.hot.text()), &*e.rel);
|
||||
assert_eq!(row.name(d.hot.text()), e.name());
|
||||
assert_eq!(row.dir(d.hot.text()), e.dir());
|
||||
assert_eq!(row.name_mask, char_mask(e.name()));
|
||||
assert_eq!(row.rel_mask, char_mask(&e.rel));
|
||||
assert_eq!(row.dir_mask, char_mask(e.dir()));
|
||||
assert_eq!(row.ascii, e.rel.is_ascii());
|
||||
assert_eq!(row.sym_start, e.sym_start);
|
||||
assert_eq!(row.sym_len, e.sym_len);
|
||||
assert_eq!(row.sym_mask, d.symbols_of(e).fold(0u64, |m, s| m | char_mask(s.name)));
|
||||
assert_eq!(d.syms.head.len(), d.syms.container.len());
|
||||
assert_eq!(d.syms.head.len(), d.syms.kind.len());
|
||||
assert_eq!(d.syms.head.len(), d.syms.range.len());
|
||||
assert!(e.sym_len == 0 || (e.sym_start as usize) < d.syms.head.len());
|
||||
assert!(e.sym_start + e.sym_len <= d.syms.head.len() as u32);
|
||||
}
|
||||
for (rel, (path, count, row)) in &d.dirs {
|
||||
let row = &d.dirs_hot.rows[*row as usize];
|
||||
assert_eq!(row.rel(d.dirs_hot.text()), &**rel);
|
||||
assert_eq!(&row.path, path);
|
||||
assert!(*count > 0);
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let name = &trimmed[row.name_start as usize..];
|
||||
assert_eq!(row.name_mask, char_mask(name));
|
||||
assert_eq!(row.rel_mask, char_mask(trimmed));
|
||||
}
|
||||
assert_eq!(d.dirs.len(), d.dirs_hot.rows.len());
|
||||
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();
|
||||
d.insert(entry("a/b/c.rs", 0));
|
||||
d.insert(entry("a/d.rs", 0));
|
||||
d.insert(entry("a/d.rs", 0));
|
||||
d.insert(entry("top.rs", 0));
|
||||
insert(&mut d, "a/b/c.rs", 0);
|
||||
insert(&mut d, "a/d.rs", 0);
|
||||
insert(&mut d, "a/d.rs", 0);
|
||||
insert(&mut d, "top.rs", 0);
|
||||
let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect();
|
||||
dirs.sort();
|
||||
assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]);
|
||||
assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b"));
|
||||
assert_consistent(&d);
|
||||
d.remove(Path::new("/r/a/b/c.rs"));
|
||||
assert!(!d.dirs.contains_key("a/b/"));
|
||||
assert_consistent(&d);
|
||||
d.remove_tree(Path::new("/r/a"));
|
||||
assert!(d.dirs.is_empty());
|
||||
assert_consistent(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_replace_remove_keep_counts() {
|
||||
let mut d = IndexData::default();
|
||||
d.insert(entry("a/b.rs", 2));
|
||||
d.insert(entry("c.rs", 1));
|
||||
insert(&mut d, "a/b.rs", 2);
|
||||
insert(&mut d, "c.rs", 1);
|
||||
assert_eq!((d.file_count, d.symbol_count), (2, 3));
|
||||
d.insert(entry("a/b.rs", 5));
|
||||
insert(&mut d, "a/b.rs", 5);
|
||||
assert_eq!((d.file_count, d.symbol_count), (2, 6));
|
||||
assert_consistent(&d);
|
||||
assert!(d.remove(Path::new("/r/c.rs")).is_some());
|
||||
assert_eq!((d.file_count, d.symbol_count), (1, 5));
|
||||
d.insert(entry("d.rs", 0));
|
||||
assert_consistent(&d);
|
||||
insert(&mut d, "d.rs", 0);
|
||||
assert_eq!(d.files.len(), 2, "slot reused");
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.remove_tree(Path::new("/r/a")), vec![PathBuf::from("/r/a/b.rs")]);
|
||||
assert_eq!((d.file_count, d.symbol_count), (1, 0));
|
||||
let e = d.get(Path::new("/r/d.rs")).unwrap();
|
||||
assert_eq!((e.name(), e.dir()), ("d.rs", ""));
|
||||
d.insert(entry("e.rs", 1));
|
||||
d.insert(entry("f.rs", 1));
|
||||
insert(&mut d, "e.rs", 1);
|
||||
insert(&mut d, "f.rs", 1);
|
||||
d.remove(Path::new("/r/e.rs"));
|
||||
d.remove(Path::new("/r/f.rs"));
|
||||
d.shrink();
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.files.len(), 2, "trailing free slot dropped");
|
||||
assert_eq!(d.free, vec![0]);
|
||||
d.insert(entry("g.rs", 1));
|
||||
insert(&mut d, "g.rs", 1);
|
||||
assert_eq!((d.file_count, d.files.len()), (2, 2));
|
||||
assert_consistent(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -312,4 +941,65 @@ mod tests {
|
||||
assert_eq!(char_mask("ab") & char_mask("Bad"), char_mask("ab"));
|
||||
assert_ne!(char_mask("z") & char_mask("abc"), char_mask("z"));
|
||||
}
|
||||
|
||||
fn insert_symbols(d: &mut IndexData, path: &str, symbols: Vec<StoredSymbol>) {
|
||||
let record = FileRecord { symbols, ..Default::default() };
|
||||
let (e, s) = FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record);
|
||||
d.insert(e, s);
|
||||
}
|
||||
|
||||
fn symbol(n: &str) -> StoredSymbol {
|
||||
StoredSymbol { name: n.to_owned(), kind: SymbolKind::Function, container: None, range: Range::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_keeps_entries_and_records_in_sync() {
|
||||
let mut d = IndexData::default();
|
||||
insert(&mut d, "a.rs", 3);
|
||||
insert(&mut d, "b/c.rs", 2);
|
||||
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
|
||||
d.shrink();
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.syms.head.len() as u32, d.symbol_count, "compaction drops dead symbol rows");
|
||||
let names = |d: &IndexData, p: &str| -> Vec<String> {
|
||||
let e = d.get(Path::new(p)).unwrap();
|
||||
d.record(e).symbols.into_iter().map(|s| s.name).collect()
|
||||
};
|
||||
assert_eq!(names(&d, "/r/a.rs"), ["fresh"]);
|
||||
assert_eq!(names(&d, "/r/b/c.rs"), ["s0", "s1"]);
|
||||
assert_eq!(d.symbol_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_replacements_reclaim_symbol_storage() {
|
||||
let mut d = IndexData::default();
|
||||
insert_symbols(&mut d, "a.rs", (0..5_000).map(|i| symbol(&format!("old{i}"))).collect());
|
||||
assert_eq!(d.syms.head.len(), 5_000);
|
||||
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
|
||||
assert_eq!(d.syms.head.len(), 1, "dead symbol rows are reclaimed");
|
||||
assert_eq!((d.symbol_count, d.file_count), (1, 1));
|
||||
assert_consistent(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_removal_leaves_no_hot_rows_or_dirs() {
|
||||
let mut d = IndexData::default();
|
||||
insert(&mut d, "a/b/one.rs", 2);
|
||||
insert(&mut d, "a/b/two.rs", 3);
|
||||
insert(&mut d, "a/three.rs", 1);
|
||||
insert(&mut d, "elsewhere/four.rs", 1);
|
||||
assert_eq!(d.remove_tree(Path::new("/r/a")).len(), 3);
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), 1);
|
||||
assert!(d.hot.row(0).is_none(), "removed slots have no live row");
|
||||
assert_eq!(d.dirs.len(), 1, "elsewhere/ only");
|
||||
assert_eq!(d.dirs_hot.rows.len(), 1);
|
||||
assert_eq!(d.symbol_count, 1);
|
||||
d.shrink();
|
||||
assert_eq!(d.syms.head.len() as u32, d.symbol_count);
|
||||
// Slots are reusable and the new row replaces the tombstone.
|
||||
insert(&mut d, "a/five.rs", 2);
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.file_count, 2);
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,22 @@ 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)
|
||||
}
|
||||
@@ -146,6 +162,11 @@ 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())
|
||||
@@ -227,6 +248,30 @@ 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> {
|
||||
|
||||
@@ -199,12 +199,11 @@ impl ProjectIndex {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
file.symbols
|
||||
.iter()
|
||||
data.symbols_of(file)
|
||||
.map(|s| ide_language::SymbolTag {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_ref().map(ToString::to_string),
|
||||
container: s.container.map(ToString::to_string),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
@@ -231,7 +230,7 @@ impl ProjectIndex {
|
||||
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
let recent = self.shared.recent.lock().clone();
|
||||
let d = self.shared.data.read();
|
||||
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
|
||||
matching::find_files(&d, &matching::Recents::new(&recent), query, limit)
|
||||
}
|
||||
|
||||
/// All indexed files with this exact basename, for resolving printed source locations.
|
||||
@@ -274,7 +273,7 @@ impl ProjectIndex {
|
||||
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
|
||||
let recent = self.shared.recent.lock().clone();
|
||||
let d = self.shared.data.read();
|
||||
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
|
||||
matching::find_symbols(&d, &matching::Recents::new(&recent), query, filter, limit)
|
||||
}
|
||||
|
||||
/// Scoped symbol search with cooperative interruption, including lock waits.
|
||||
@@ -303,15 +302,7 @@ impl ProjectIndex {
|
||||
break data;
|
||||
}
|
||||
};
|
||||
matching::find_symbols_until(
|
||||
&data,
|
||||
&matching::Recents { paths: &recent },
|
||||
query,
|
||||
filter,
|
||||
limit,
|
||||
include,
|
||||
stopped,
|
||||
)
|
||||
matching::find_symbols_until(&data, &matching::Recents::new(&recent), query, filter, limit, include, stopped)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
|
||||
@@ -523,7 +514,8 @@ pub mod bench_support {
|
||||
})
|
||||
.collect();
|
||||
let record = FileRecord { symbols, ..Default::default() };
|
||||
data.insert(FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record));
|
||||
let (entry, symbols) = FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record);
|
||||
data.insert(entry, symbols);
|
||||
}
|
||||
let recent = data.paths().take(MAX_RECENT).cloned().collect();
|
||||
Self { data, recent }
|
||||
@@ -538,7 +530,7 @@ pub mod bench_support {
|
||||
}
|
||||
|
||||
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
|
||||
matching::find_files(&self.data, &matching::Recents::new(&self.recent), query, limit)
|
||||
}
|
||||
|
||||
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
@@ -546,7 +538,7 @@ pub mod bench_support {
|
||||
}
|
||||
|
||||
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
|
||||
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
|
||||
matching::find_symbols(&self.data, &matching::Recents::new(&self.recent), query, filter, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
-108
@@ -4,14 +4,19 @@
|
||||
//! (bit-mask pre-filter, nucleo score, exact/prefix boosts) and keeps the best
|
||||
//! few; the survivors then get match indices, CamelHump and recency boosts and
|
||||
//! are re-sorted.
|
||||
//!
|
||||
//! The scan streams the compact tables in `data` (`HotFiles`/`HotDirs`/`SymTable`):
|
||||
//! only the hot fields per candidate and strings from contiguous arenas, with the
|
||||
//! thread-local matcher and one UTF-32 buffer per scan rather than per candidate.
|
||||
|
||||
use crate::data::{FileEntry, IndexData, char_mask};
|
||||
use crate::data::{DirRow, FileEntry, FileRow, IndexData, SymTable, char_mask};
|
||||
use crate::{FileMatch, SymbolFilter, SymbolMatch};
|
||||
use ide_core::SymbolKind;
|
||||
use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
|
||||
use rayon::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const TIER_NAME: u32 = 1 << 20;
|
||||
@@ -54,6 +59,11 @@ impl Needle {
|
||||
m.fuzzy_match(Utf32Str::new(hay, buf), self.utf32.slice(..)).map(u32::from)
|
||||
}
|
||||
|
||||
/// `hay` is pure ASCII: skip `Utf32Str::new`'s check and UTF-32 conversion.
|
||||
fn score_ascii(&self, m: &mut Matcher, hay: &str) -> Option<u32> {
|
||||
m.fuzzy_match(Utf32Str::Ascii(hay.as_bytes()), self.utf32.slice(..)).map(u32::from)
|
||||
}
|
||||
|
||||
fn indices(&self, m: &mut Matcher, hay: &str, buf: &mut Vec<char>, out: &mut Vec<u32>) -> Option<u32> {
|
||||
out.clear();
|
||||
let s = m.fuzzy_indices(Utf32Str::new(hay, buf), self.utf32.slice(..), out).map(u32::from);
|
||||
@@ -105,6 +115,9 @@ struct Cand {
|
||||
slot: u32,
|
||||
sym: u32,
|
||||
score: u32,
|
||||
/// Candidate name/rel length: the first tie-break key, cached so ranking a pool
|
||||
/// does not chase it through the tables.
|
||||
len: u32,
|
||||
}
|
||||
|
||||
fn keep_best(mut v: Vec<Cand>, k: usize, tiebreak: &impl Fn(&Cand, &Cand) -> Ordering) -> Vec<Cand> {
|
||||
@@ -119,7 +132,7 @@ fn scan(
|
||||
data: &IndexData,
|
||||
pool: usize,
|
||||
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
|
||||
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
|
||||
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
|
||||
) -> Vec<Cand> {
|
||||
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
|
||||
}
|
||||
@@ -129,18 +142,22 @@ fn scan_until(
|
||||
pool: usize,
|
||||
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
|
||||
stopped: &(impl Fn() -> bool + Sync),
|
||||
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
|
||||
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
|
||||
) -> Option<Vec<Cand>> {
|
||||
data.files
|
||||
// One row per slot, so this is exactly the old scan over `files`: same item
|
||||
// order and count, and free slots are skipped exactly like `None` was, keeping
|
||||
// the pruning boundaries (and therefore the candidate pool) unchanged.
|
||||
data.hot
|
||||
.rows
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.with_min_len(256)
|
||||
.try_fold(Vec::new, |mut acc, (slot, e)| {
|
||||
.try_fold(Vec::new, |mut acc, (slot, row)| {
|
||||
if stopped() {
|
||||
return None;
|
||||
}
|
||||
if let Some(e) = e {
|
||||
per_file(slot as u32, e, &mut acc);
|
||||
if row.live {
|
||||
with_matcher(|m| per_file(slot as u32, row, m, &mut acc));
|
||||
if acc.len() >= pool * 2 {
|
||||
acc = keep_best(acc, pool, tiebreak);
|
||||
}
|
||||
@@ -155,15 +172,22 @@ fn scan_until(
|
||||
|
||||
pub(crate) struct Recents<'a> {
|
||||
pub paths: &'a [PathBuf],
|
||||
/// Position of each path, so ranking a survivor does not scan the list.
|
||||
/// Duplicates keep their first position, like `position`.
|
||||
ranks: HashMap<&'a Path, usize>,
|
||||
}
|
||||
|
||||
impl Recents<'_> {
|
||||
fn rank(&self, path: &std::path::Path) -> Option<usize> {
|
||||
self.paths.iter().position(|p| p == path)
|
||||
impl<'a> Recents<'a> {
|
||||
pub(crate) fn new(paths: &'a [PathBuf]) -> Self {
|
||||
let mut ranks = HashMap::with_capacity(paths.len());
|
||||
for (i, p) in paths.iter().enumerate() {
|
||||
ranks.entry(p.as_path()).or_insert(i);
|
||||
}
|
||||
Self { paths, ranks }
|
||||
}
|
||||
|
||||
fn bonus(&self, path: &std::path::Path) -> u32 {
|
||||
self.rank(path).map_or(0, |r| RECENT_MAX.saturating_sub(r as u32 * 12))
|
||||
fn bonus(&self, path: &Path) -> u32 {
|
||||
self.ranks.get(path).map_or(0, |&r| RECENT_MAX.saturating_sub(r as u32 * 12))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,29 +233,31 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit
|
||||
Some((dir, name)) => FileQuery::Path { dir: Needle::new(dir), name: Needle::new(name) },
|
||||
None => FileQuery::Name(Needle::new(&q)),
|
||||
};
|
||||
let tiebreak = |a: &Cand, b: &Cand| {
|
||||
let (ea, eb) = (file(data, a.slot), file(data, b.slot));
|
||||
ea.rel.len().cmp(&eb.rel.len()).then_with(|| ea.rel.cmp(&eb.rel))
|
||||
};
|
||||
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
|
||||
let pool = limit * 4 + 64;
|
||||
let mut cands = scan(data, pool, &tiebreak, |slot, e, acc| {
|
||||
let mut cands = scan(data, pool, &tiebreak, |slot, row, m, acc| {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
|
||||
acc.push(Cand { slot, sym: u32::MAX, score });
|
||||
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
|
||||
acc.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
|
||||
}
|
||||
});
|
||||
// Recent files always compete for the final ranking.
|
||||
for p in recents.paths {
|
||||
if let Some(e) = data.get(p) {
|
||||
with_matcher(|m| {
|
||||
let mut buf = Vec::new();
|
||||
for p in recents.paths {
|
||||
if data.get(p).is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(slot) = data.slot(p) else { continue };
|
||||
if !cands.iter().any(|c| c.slot == slot) {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
|
||||
cands.push(Cand { slot, sym: u32::MAX, score });
|
||||
}
|
||||
if cands.iter().any(|c| c.slot == slot) {
|
||||
continue;
|
||||
}
|
||||
let Some(row) = data.hot.row_of_slot(slot) else { continue };
|
||||
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
|
||||
cands.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut out: Vec<(u32, &FileEntry, Vec<u32>)> = with_matcher(|m| {
|
||||
let mut buf = Vec::new();
|
||||
cands
|
||||
@@ -273,30 +299,46 @@ pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec<File
|
||||
}
|
||||
let by_path = q.contains('/');
|
||||
let needle = Needle::new(q.trim_end_matches('/'));
|
||||
let text = data.dirs_hot.text();
|
||||
// (score, rel without the trailing `/`, char offset of the name when it matched by name)
|
||||
let score = |m: &mut Matcher, buf: &mut Vec<char>, rel: &str| -> Option<(u32, Option<u32>)> {
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let start = trimmed.rfind('/').map_or(0, |i| i + 1);
|
||||
let name = &trimmed[start..];
|
||||
if !by_path
|
||||
&& needle.mask & char_mask(name) == needle.mask
|
||||
&& let Some(s) = needle.score(m, name, buf)
|
||||
{
|
||||
return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32)));
|
||||
}
|
||||
if needle.mask & char_mask(trimmed) != needle.mask {
|
||||
return None;
|
||||
}
|
||||
needle.score(m, trimmed, buf).map(|s| (s, None))
|
||||
};
|
||||
let mut best: Vec<(u32, &str, &Path, Option<u32>)> = data
|
||||
.dirs
|
||||
let (mut best, _): (Vec<_>, Vec<char>) = data
|
||||
.dirs_hot
|
||||
.rows
|
||||
.par_iter()
|
||||
.filter_map(|(rel, (path, _))| {
|
||||
let mut buf = Vec::new();
|
||||
with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at))
|
||||
})
|
||||
.collect();
|
||||
.fold(
|
||||
|| (Vec::new(), Vec::new()),
|
||||
|(mut found, mut buf), row: &DirRow| {
|
||||
let rel = row.rel(text);
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let name = &trimmed[row.name_start as usize..];
|
||||
with_matcher(|m| {
|
||||
let hit = if !by_path
|
||||
&& needle.mask & row.name_mask == needle.mask
|
||||
&& let Some(s) = needle.score(m, name, &mut buf)
|
||||
{
|
||||
Some((
|
||||
TIER_NAME + s * 4 + needle.name_boost(name),
|
||||
Some(trimmed[..row.name_start as usize].chars().count() as u32),
|
||||
))
|
||||
} else if needle.mask & row.rel_mask != needle.mask {
|
||||
None
|
||||
} else {
|
||||
needle.score(m, trimmed, &mut buf).map(|s| (s, None))
|
||||
};
|
||||
if let Some((s, at)) = hit {
|
||||
found.push((s, rel, row.path.as_path(), at));
|
||||
}
|
||||
});
|
||||
(found, buf)
|
||||
},
|
||||
)
|
||||
.reduce(
|
||||
|| (Vec::new(), Vec::new()),
|
||||
|mut a, b| {
|
||||
a.0.extend(b.0);
|
||||
a
|
||||
},
|
||||
);
|
||||
let order = |a: &(u32, &str, &Path, Option<u32>), b: &(u32, &str, &Path, Option<u32>)| {
|
||||
b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1))
|
||||
};
|
||||
@@ -338,33 +380,39 @@ fn file(data: &IndexData, slot: u32) -> &FileEntry {
|
||||
data.files[slot as usize].as_ref().expect("candidate slots are live")
|
||||
}
|
||||
|
||||
fn base_file_score(m: &mut Matcher, q: &FileQuery, e: &FileEntry, buf: &mut Vec<char>) -> Option<u32> {
|
||||
fn base_file_score(m: &mut Matcher, q: &FileQuery, row: &FileRow, text: &str, buf: &mut Vec<char>) -> Option<u32> {
|
||||
// Strings are sliced only once their mask passes, so a rejected row costs two masks.
|
||||
match q {
|
||||
FileQuery::Name(n) => {
|
||||
if n.mask & e.name_mask == n.mask
|
||||
&& let Some(s) = n.score(m, e.name(), buf)
|
||||
{
|
||||
return Some(TIER_NAME + s * 4 + n.name_boost(e.name()));
|
||||
if n.mask & row.name_mask == n.mask {
|
||||
let name = row.name(text);
|
||||
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) };
|
||||
if let Some(s) = s {
|
||||
return Some(TIER_NAME + s * 4 + n.name_boost(name));
|
||||
}
|
||||
}
|
||||
if n.mask & e.rel_mask == n.mask {
|
||||
return n.score(m, &e.rel, buf);
|
||||
if n.mask & row.rel_mask == n.mask {
|
||||
let rel = row.rel(text);
|
||||
return if row.ascii { n.score_ascii(m, rel) } else { n.score(m, rel, buf) };
|
||||
}
|
||||
None
|
||||
}
|
||||
FileQuery::Path { dir, name } => {
|
||||
let name_score = if name.is_empty() {
|
||||
FileQuery::Path { dir, name: n } => {
|
||||
let name_score = if n.is_empty() {
|
||||
0
|
||||
} else {
|
||||
if name.mask & e.name_mask != name.mask {
|
||||
if n.mask & row.name_mask != n.mask {
|
||||
return None;
|
||||
}
|
||||
name.score(m, e.name(), buf)? * 4 + name.name_boost(e.name())
|
||||
let name = row.name(text);
|
||||
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) }?;
|
||||
s * 4 + n.name_boost(name)
|
||||
};
|
||||
let dir_str = e.dir();
|
||||
if dir.mask & char_mask(dir_str) != dir.mask {
|
||||
if dir.mask & row.dir_mask != dir.mask {
|
||||
return None;
|
||||
}
|
||||
let dir_score = dir.score(m, dir_str, buf)?;
|
||||
let dir_str = row.dir(text);
|
||||
let dir_score = if row.ascii { dir.score_ascii(m, dir_str) } else { dir.score(m, dir_str, buf) }?;
|
||||
Some(TIER_NAME + name_score + dir_score)
|
||||
}
|
||||
}
|
||||
@@ -396,22 +444,21 @@ fn symbol_score(
|
||||
m: &mut Matcher,
|
||||
name: &Needle,
|
||||
container: Option<&Needle>,
|
||||
s: &crate::data::Sym,
|
||||
syms: &SymTable,
|
||||
i: u32,
|
||||
buf: &mut Vec<char>,
|
||||
) -> Option<u32> {
|
||||
if name.mask & s.mask != name.mask {
|
||||
return None;
|
||||
}
|
||||
let mut score = if name.is_empty() { 0 } else { name.score(m, &s.name, buf)? * 4 };
|
||||
score += name.name_boost(&s.name);
|
||||
let symbol = syms.name(i);
|
||||
let mut score = if name.is_empty() { 0 } else { name.score(m, symbol, buf)? * 4 };
|
||||
score += name.name_boost(symbol);
|
||||
if let Some(cq) = container {
|
||||
let c = s.container.as_deref()?;
|
||||
let c = syms.container(i)?;
|
||||
if cq.mask & char_mask(c) != cq.mask {
|
||||
return None;
|
||||
}
|
||||
score += cq.score(m, c, buf)? + cq.name_boost(c) / 4;
|
||||
}
|
||||
if s.kind.is_type_like() {
|
||||
if syms.kind(i).is_type_like() {
|
||||
score += TYPE_LIKE;
|
||||
}
|
||||
Some(score)
|
||||
@@ -455,30 +502,35 @@ pub(crate) fn find_symbols_until(
|
||||
let (container_q, name_q) = split_symbol_query(&q);
|
||||
let name = Needle::new(name_q);
|
||||
let container = container_q.map(Needle::new);
|
||||
let sym = |c: &Cand| &file(data, c.slot).symbols[c.sym as usize];
|
||||
let tiebreak = |a: &Cand, b: &Cand| {
|
||||
let (sa, sb) = (sym(a), sym(b));
|
||||
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
|
||||
};
|
||||
let syms = &data.syms;
|
||||
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
|
||||
let pool = limit * 4 + 64;
|
||||
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
|
||||
if e.symbols.is_empty() || !include(&e.path) {
|
||||
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, row, m, acc| {
|
||||
// Whole-file pre-filter: when the union of the file's symbol masks
|
||||
// already misses a query character, no symbol can match.
|
||||
if row.sym_len == 0 || name.mask & row.sym_mask != name.mask {
|
||||
return;
|
||||
}
|
||||
with_matcher(|m| {
|
||||
let mut buf = Vec::new();
|
||||
for (i, s) in e.symbols.iter().enumerate() {
|
||||
if stopped() {
|
||||
break;
|
||||
}
|
||||
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
|
||||
continue;
|
||||
}
|
||||
if let Some(score) = symbol_score(m, &name, container.as_ref(), s, &mut buf) {
|
||||
acc.push(Cand { slot, sym: i as u32, score });
|
||||
}
|
||||
let e = file(data, slot);
|
||||
if !include(&e.path) {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
for (k, head) in syms.heads(row.sym_start, row.sym_len).iter().enumerate() {
|
||||
if stopped() {
|
||||
break;
|
||||
}
|
||||
});
|
||||
if filter == SymbolFilter::Types && !syms.kind(row.sym_start + k as u32).is_type_like() {
|
||||
continue;
|
||||
}
|
||||
if name.mask & head.mask != name.mask {
|
||||
continue;
|
||||
}
|
||||
let i = row.sym_start + k as u32;
|
||||
if let Some(score) = symbol_score(m, &name, container.as_ref(), syms, i, &mut buf) {
|
||||
acc.push(Cand { slot, sym: i, score, len: head.name.len });
|
||||
}
|
||||
}
|
||||
})?;
|
||||
if stopped() {
|
||||
return None;
|
||||
@@ -489,33 +541,32 @@ pub(crate) fn find_symbols_until(
|
||||
.into_iter()
|
||||
.take_while(|_| !stopped())
|
||||
.filter_map(|c| {
|
||||
let s = sym(&c);
|
||||
let symbol = syms.name(c.sym);
|
||||
let e = file(data, c.slot);
|
||||
let mut idx = Vec::new();
|
||||
if !name.is_empty() {
|
||||
name.indices(m, &s.name, &mut buf, &mut idx)?;
|
||||
name.indices(m, symbol, &mut buf, &mut idx)?;
|
||||
}
|
||||
let bonus = hump_bonus(&s.name, &idx) + recents.bonus(&e.path) / 4;
|
||||
let bonus = hump_bonus(symbol, &idx) + recents.bonus(&e.path) / 4;
|
||||
Some((c.score + bonus, c, idx))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
out.sort_by(|a, b| {
|
||||
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
|
||||
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| syms.range(a.1.sym).cmp(&syms.range(b.1.sym)))
|
||||
});
|
||||
out.truncate(limit);
|
||||
let results = out
|
||||
.into_iter()
|
||||
.map(|(score, c, indices)| {
|
||||
let e = file(data, c.slot);
|
||||
let s = &e.symbols[c.sym as usize];
|
||||
SymbolMatch {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_deref().map(str::to_owned),
|
||||
name: syms.name(c.sym).to_string(),
|
||||
kind: syms.kind(c.sym),
|
||||
container: syms.container(c.sym).map(str::to_owned),
|
||||
path: e.path.clone().into(),
|
||||
rel_path: e.rel.to_string(),
|
||||
range: s.range,
|
||||
range: syms.range(c.sym),
|
||||
score,
|
||||
indices,
|
||||
}
|
||||
@@ -541,24 +592,25 @@ pub(crate) fn symbols_named_within(
|
||||
) -> Vec<SymbolMatch> {
|
||||
let mask = char_mask(name);
|
||||
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
|
||||
let syms = &data.syms;
|
||||
let mut out: Vec<SymbolMatch> = data
|
||||
.files
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|e| within(e))
|
||||
.flat_map(|e| {
|
||||
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
|
||||
move |s| SymbolMatch {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_deref().map(str::to_owned),
|
||||
(e.sym_start..e.sym_start + e.sym_len)
|
||||
.filter(|&i| syms.mask(i) == mask && syms.name(i) == name && kind.is_none_or(|k| syms.kind(i) == k))
|
||||
.map(move |i| SymbolMatch {
|
||||
name: syms.name(i).to_string(),
|
||||
kind: syms.kind(i),
|
||||
container: syms.container(i).map(str::to_owned),
|
||||
path: e.path.clone().into(),
|
||||
rel_path: e.rel.to_string(),
|
||||
range: s.range,
|
||||
range: syms.range(i),
|
||||
score: 0,
|
||||
indices: Vec::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path).then_with(|| a.range.cmp(&b.range)));
|
||||
|
||||
+566
-37
@@ -1,75 +1,408 @@
|
||||
//! Persistence of file records in redb, so a restart only re-parses changed files.
|
||||
|
||||
use crate::data::FileRecord;
|
||||
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
|
||||
use crate::filter::{PathFilter, mtime_of};
|
||||
use ide_core::{Range, SymbolKind};
|
||||
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 {
|
||||
/// Write-locked only for [`Store::compact`].
|
||||
db: RwLock<Database>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// 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.
|
||||
/// 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.
|
||||
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 file = cache_dir.join("index.redb");
|
||||
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 db = match Database::create(&file) {
|
||||
Ok(db) => db,
|
||||
Err(redb::DatabaseError::DatabaseAlreadyOpen) => {
|
||||
tracing::warn!("index cache {} is in use; indexing in memory only", file.display());
|
||||
return None;
|
||||
return Err(format!("{} is in use", file.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("index cache {} unreadable ({e}); rebuilding", file.display());
|
||||
let _ = std::fs::remove_file(&file);
|
||||
Database::create(&file).map_err(|e| tracing::warn!("index cache: {e}")).ok()?
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
Database::create(&file).map_err(err)?
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
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)),
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
let _ = std::fs::remove_file(&self.recent_file);
|
||||
Self::reset_db(&db, &self.stamp)?;
|
||||
}
|
||||
Some(store)
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
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)),
|
||||
};
|
||||
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(&self, stamp: &str) -> Result<()> {
|
||||
let txn = self.db.read().begin_write().map_err(err)?;
|
||||
fn reset_db(db: &Database, stamp: &str) -> Result<()> {
|
||||
let txn = db.begin_write().map_err(err)?;
|
||||
match txn.delete_table(FILES) {
|
||||
Ok(_) | Err(TableError::TableDoesNotExist(_)) => {}
|
||||
Err(e) => return Err(err(e)),
|
||||
@@ -82,16 +415,31 @@ 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)>> {
|
||||
let txn = self.db.read().begin_read().map_err(err)?;
|
||||
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(FILES) {
|
||||
Ok(t) => t,
|
||||
Err(TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
|
||||
Err(e) => return Err(err(e)),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for item in table.iter().map_err(err)? {
|
||||
let iter = table.iter().map_err(err)?;
|
||||
let mut out = Vec::with_capacity(iter.size_hint().0);
|
||||
for item in iter {
|
||||
let (k, v) = item.map_err(err)?;
|
||||
if let Ok(rec) = postcard::from_bytes::<FileRecord>(v.value()) {
|
||||
out.push((k.value().to_owned(), rec));
|
||||
@@ -100,10 +448,115 @@ 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<()> {
|
||||
let mut txn = self.db.read().begin_write().map_err(err)?;
|
||||
self.ensure_db()?;
|
||||
let db = self.db.read();
|
||||
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
|
||||
if !durable {
|
||||
txn.set_durability(Durability::None).map_err(err)?;
|
||||
}
|
||||
@@ -120,25 +573,47 @@ 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.db.write().compact().map_err(err)
|
||||
self.ensure_db()?;
|
||||
let mut db = self.db.write();
|
||||
db.as_mut().expect("ensured").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<()> {
|
||||
let txn = self.db.read().begin_write().map_err(err)?;
|
||||
self.ensure_db()?;
|
||||
let db = self.db.read();
|
||||
let txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
|
||||
txn.open_table(META).map_err(err)?.insert(key, value).map_err(err)?;
|
||||
txn.commit().map_err(err)
|
||||
}
|
||||
@@ -183,4 +658,58 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -772,6 +772,49 @@ 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")] {
|
||||
@@ -834,3 +877,42 @@ 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"]);
|
||||
}
|
||||
+312
-48
@@ -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};
|
||||
use crate::filter::{Found, PathFilter};
|
||||
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol, safe_rel};
|
||||
use crate::filter::{Found, PathFilter, mtime_of};
|
||||
use crate::store::Store;
|
||||
use ide_core::{IdeEvent, SharedSink, TaskId};
|
||||
use ide_language::{LanguageRegistry, Syntax};
|
||||
@@ -12,12 +12,16 @@ 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};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
/// 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 {
|
||||
@@ -55,6 +59,11 @@ 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 {
|
||||
@@ -79,6 +88,8 @@ impl Shared {
|
||||
queue: Mutex::default(),
|
||||
wake: Condvar::new(),
|
||||
idle: Condvar::new(),
|
||||
snapshot_due: Mutex::default(),
|
||||
pending_dirs: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +146,15 @@ impl Shared {
|
||||
q.busy = true;
|
||||
}
|
||||
if scan {
|
||||
self.load_persisted();
|
||||
self.full_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);
|
||||
}
|
||||
} else {
|
||||
self.indexing.store(false, Ordering::SeqCst);
|
||||
}
|
||||
@@ -147,17 +165,38 @@ impl Shared {
|
||||
self.idle.notify_all();
|
||||
}
|
||||
loop {
|
||||
let mut save_due = false;
|
||||
let jobs: Vec<Job> = {
|
||||
let mut q = self.queue.lock();
|
||||
while q.jobs.is_empty() && !self.is_cancelled() {
|
||||
self.wake.wait(&mut q);
|
||||
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);
|
||||
}
|
||||
}
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
if save_due {
|
||||
Vec::new()
|
||||
} else {
|
||||
q.busy = true;
|
||||
q.jobs.drain(..).collect()
|
||||
}
|
||||
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;
|
||||
@@ -167,27 +206,114 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_persisted(&self) {
|
||||
let Some(store) = &self.store else { return };
|
||||
/// 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();
|
||||
let filter = self.filter();
|
||||
let root = filter.root().to_owned();
|
||||
match store.load() {
|
||||
Ok(records) => {
|
||||
let entries: Vec<FileEntry> = 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 in entries {
|
||||
d.insert(e);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("index cache load failed: {e}"),
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -198,6 +324,72 @@ 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);
|
||||
@@ -212,11 +404,24 @@ impl Shared {
|
||||
self.emit_state();
|
||||
}
|
||||
|
||||
fn full_scan(&self) {
|
||||
self.scan(None);
|
||||
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 scan(&self, dirs: Option<&[PathBuf]>) {
|
||||
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) {
|
||||
let started = Instant::now();
|
||||
self.indexing.store(true, Ordering::SeqCst);
|
||||
self.emit_state();
|
||||
@@ -231,14 +436,7 @@ impl Shared {
|
||||
});
|
||||
|
||||
let filter = self.filter();
|
||||
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));
|
||||
}
|
||||
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
@@ -285,6 +483,17 @@ 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,
|
||||
@@ -321,8 +530,7 @@ impl Shared {
|
||||
match self.extract(f, prev_hash) {
|
||||
Extracted::Unchanged => {
|
||||
// Same content, new mtime: keep the symbols, refresh metadata.
|
||||
let d = self.data.read();
|
||||
let mut rec = d.get(&f.path)?.record();
|
||||
let mut rec = self.data.read().record_of(&f.path)?;
|
||||
rec.mtime = f.mtime;
|
||||
rec.size = f.size;
|
||||
rec.ignored = f.ignored;
|
||||
@@ -338,16 +546,46 @@ impl Shared {
|
||||
let puts: Vec<(String, FileRecord)> =
|
||||
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
|
||||
{
|
||||
let entries: Vec<FileEntry> =
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
|
||||
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
|
||||
let mut d = self.data.write();
|
||||
for e in entries {
|
||||
d.insert(e);
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -407,6 +645,17 @@ 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();
|
||||
@@ -421,14 +670,14 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
if !rescan {
|
||||
self.scan(Some(&roots));
|
||||
self.scan(Some(&roots), None, SystemTime::now());
|
||||
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();
|
||||
self.full_scan(None, SystemTime::now());
|
||||
changed.clear();
|
||||
removed.clear();
|
||||
}
|
||||
@@ -523,6 +772,7 @@ impl Shared {
|
||||
if touched {
|
||||
self.persist(&[], &[], true);
|
||||
self.emit_state();
|
||||
self.schedule_snapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,3 +792,17 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,10 @@ pub trait Handler: Send + Sync + 'static {
|
||||
|
||||
/// Called once the response to a `method` request is queued: what it sends follows the response.
|
||||
fn after_response(&self, _method: &str) {}
|
||||
|
||||
/// Adjusts every message from the peer (responses included) before it is dispatched, e.g.
|
||||
/// to repair values the peer spells more loosely than the types that read them accept.
|
||||
fn normalize_incoming(&self, _message: &mut Value) {}
|
||||
}
|
||||
|
||||
/// A [`Handler`] that ignores notifications and rejects requests.
|
||||
@@ -585,9 +589,10 @@ impl Peer {
|
||||
.expect("spawn jsonrpc notification thread");
|
||||
}
|
||||
|
||||
// Reader: parses and dispatches; never runs handler code itself.
|
||||
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
|
||||
{
|
||||
let inner = inner.clone();
|
||||
let handler = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let seen_message = AtomicBool::new(false);
|
||||
@@ -610,7 +615,7 @@ impl Peer {
|
||||
match msg {
|
||||
Ok(Some(body)) => {
|
||||
seen_message.store(true, Ordering::Relaxed);
|
||||
dispatch(&inner, ¬if_tx, &body);
|
||||
dispatch(&inner, &*handler, ¬if_tx, &body);
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
@@ -783,15 +788,16 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
let mut msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("jsonrpc: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
handler.normalize_incoming(&mut msg);
|
||||
let Value::Object(mut obj) = msg else {
|
||||
tracing::warn!("jsonrpc: ignoring non-object message");
|
||||
return;
|
||||
|
||||
@@ -541,3 +541,29 @@ async fn output_before_the_handshake_is_reported_not_dropped() {
|
||||
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
|
||||
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn incoming_messages_are_normalized_before_dispatch() {
|
||||
struct Server;
|
||||
impl Handler for Server {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, _: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
struct Client;
|
||||
impl Handler for Client {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
|
||||
Err((codes::METHOD_NOT_FOUND, "no".into()))
|
||||
}
|
||||
fn normalize_incoming(&self, message: &mut Value) {
|
||||
if let Some(result) = message.get_mut("result") {
|
||||
*result = json!("normalized");
|
||||
}
|
||||
}
|
||||
}
|
||||
let (client, _server) = pair(Arc::new(Client), Arc::new(Server));
|
||||
let v: Value = client.request("echo", "raw", None).await.unwrap();
|
||||
assert_eq!(v, json!("normalized"));
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("groovy", include_str!("../../../languages/groovy.toml")),
|
||||
("haskell", include_str!("../../../languages/haskell.toml")),
|
||||
("hcl", include_str!("../../../languages/hcl.toml")),
|
||||
("helm-template", include_str!("../../../languages/helm-template.toml")),
|
||||
("html", include_str!("../../../languages/html.toml")),
|
||||
("http", include_str!("../../../languages/http.toml")),
|
||||
("ignore", include_str!("../../../languages/ignore.toml")),
|
||||
@@ -79,6 +80,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("scala", include_str!("../../../languages/scala.toml")),
|
||||
("scss", include_str!("../../../languages/scss.toml")),
|
||||
("shellscript", include_str!("../../../languages/shellscript.toml")),
|
||||
("slint", include_str!("../../../languages/slint.toml")),
|
||||
("sql", include_str!("../../../languages/sql.toml")),
|
||||
("svelte", include_str!("../../../languages/svelte.toml")),
|
||||
("swift", include_str!("../../../languages/swift.toml")),
|
||||
@@ -547,6 +549,7 @@ mod tests {
|
||||
"Groovy",
|
||||
"Haskell",
|
||||
"Haxe",
|
||||
"Helm Template",
|
||||
"HXML",
|
||||
"HTTP",
|
||||
"IDL",
|
||||
@@ -840,6 +843,30 @@ mod tests {
|
||||
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
|
||||
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
|
||||
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
|
||||
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
|
||||
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
|
||||
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
|
||||
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
|
||||
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
|
||||
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
|
||||
assert_eq!(detect("common.gotmpl"), "helm-template");
|
||||
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
|
||||
assert_ne!(detect("site/index.tpl"), "helm-template");
|
||||
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
|
||||
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_django_fixture_has_templates_and_a_page() {
|
||||
let reg = reg();
|
||||
@@ -1046,6 +1073,7 @@ mod tests {
|
||||
("icon.svg.import", "godot-resource"),
|
||||
("export_presets.cfg", "godot-resource"),
|
||||
("water.gdshader", "gdshader"),
|
||||
("app-window.slint", "slint"),
|
||||
("notes.txt", "plaintext"),
|
||||
];
|
||||
for (path, id) in cases {
|
||||
|
||||
@@ -391,6 +391,10 @@ fn tsserver_ran_out_of_memory(text: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Handler for ClientHandler {
|
||||
fn normalize_incoming(&self, message: &mut Value) {
|
||||
crate::uri::encode_loose_file_uris(message);
|
||||
}
|
||||
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
let Some(mgr) = self.mgr.upgrade() else { return };
|
||||
match method {
|
||||
|
||||
@@ -445,8 +445,12 @@ impl Server {
|
||||
message.push_str(":\n");
|
||||
message.push_str(&tail);
|
||||
}
|
||||
let delay = self.record_crash();
|
||||
message.push_str(if delay.is_some() {
|
||||
// Starting the same build again can't help: only an update can.
|
||||
let expired = self.reported_expired_build();
|
||||
let delay = if expired { None } else { self.record_crash() };
|
||||
message.push_str(if expired {
|
||||
"\nThis build has expired; update it to start it again."
|
||||
} else if delay.is_some() {
|
||||
"\nRestarting…"
|
||||
} else {
|
||||
"\nCrashed repeatedly; restart it manually."
|
||||
@@ -757,6 +761,12 @@ impl Server {
|
||||
crash_excerpt(&lines, n)
|
||||
}
|
||||
|
||||
/// JetBrains preview builds (kotlin-lsp) refuse to run past their expiry date and
|
||||
/// exit with "This build of kotlin-server has expired".
|
||||
fn reported_expired_build(&self) -> bool {
|
||||
self.stderr.lock().iter().any(|l| is_expired_build_line(l))
|
||||
}
|
||||
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
|
||||
@@ -911,6 +921,10 @@ fn is_error_headline(line: &str) -> bool {
|
||||
named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked")
|
||||
}
|
||||
|
||||
fn is_expired_build_line(line: &str) -> bool {
|
||||
line.contains("This build of ") && line.contains(" has expired")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1071,6 +1085,31 @@ mod tests {
|
||||
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_build_is_not_restarted() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
s.push_stderr(generation, "idea.system.path=/tmp/idea-system".into());
|
||||
s.push_stderr(
|
||||
generation,
|
||||
"This build of kotlin-server has expired. The IDE will now close. Please download a new build".into(),
|
||||
);
|
||||
assert_eq!(s.on_closed(generation, &sink, Some(7)), Some(None));
|
||||
assert_eq!(s.status(), ServerStatus::Crashed);
|
||||
assert!(s.status_message().unwrap().ends_with("This build has expired; update it to start it again."));
|
||||
assert!(matches!(s.state.lock().conn, Conn::Failed));
|
||||
// A manual restart (after an update) starts it again.
|
||||
s.reset_for_restart();
|
||||
assert!(s.begin_start().is_some());
|
||||
|
||||
// Any other crash is restarted.
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
s.push_stderr(generation, "Exception in thread main".into());
|
||||
assert_eq!(s.on_closed(generation, &sink, Some(1)), Some(Some(Duration::from_millis(500))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_superseded_start_is_not_announced() {
|
||||
let s = server();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `file://` URI ⇄ path conversion (RFC 8089, VS Code-compatible encoding).
|
||||
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -22,6 +24,53 @@ const PATH: &AsciiSet = &CONTROLS
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// What RFC 3986 doesn't allow in a URI and a server may still leave unencoded in a `file:`
|
||||
/// URI (non-ASCII is always encoded); `%` stays, it starts the escapes already there.
|
||||
const LOOSE: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'[')
|
||||
.add(b'\\')
|
||||
.add(b']')
|
||||
.add(b'^')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
|
||||
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
|
||||
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
|
||||
pub fn encode_loose_file_uris(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if let Some(encoded) = encode_loose(s) {
|
||||
*s = encoded;
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
|
||||
Value::Object(map) => {
|
||||
if map.keys().any(|k| encode_loose(k).is_some()) {
|
||||
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
|
||||
}
|
||||
map.values_mut().for_each(encode_loose_file_uris);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_loose(s: &str) -> Option<String> {
|
||||
if !s.get(..5).is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) {
|
||||
return None;
|
||||
}
|
||||
match Cow::from(utf8_percent_encode(s, LOOSE)) {
|
||||
Cow::Owned(encoded) => Some(encoded),
|
||||
Cow::Borrowed(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_to_uri_string(path: &Path) -> String {
|
||||
let s = if cfg!(windows) {
|
||||
dunce::simplified(path).to_string_lossy().replace('\\', "/")
|
||||
@@ -95,6 +144,28 @@ pub use ide_core::text::is_virtual_uri;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_uris_a_server_leaves_unencoded_are_encoded() {
|
||||
// jdtls on a project under a Chinese folder (`java.usages` / references response).
|
||||
let mut v = serde_json::json!({ "result": [{
|
||||
"location": { "uri": "file:///H:/项目/online disk/A.java", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 3 } } },
|
||||
"kind": "unknown"
|
||||
}], "edit": { "changes": { "file:///tmp/目录/b.java": [] } }, "text": "项目" });
|
||||
let raw = v["result"][0]["location"].clone();
|
||||
assert!(serde_json::from_value::<lsp_types::Location>(raw).is_err(), "lsp-types rejects it as sent");
|
||||
encode_loose_file_uris(&mut v);
|
||||
let uri = v["result"][0]["location"]["uri"].as_str().unwrap();
|
||||
assert_eq!(uri, "file:///H:/%E9%A1%B9%E7%9B%AE/online%20disk/A.java");
|
||||
let location: lsp_types::Location = serde_json::from_value(v["result"][0]["location"].clone()).unwrap();
|
||||
assert!(uri_to_path(location.uri.as_str()).unwrap().ends_with("项目/online disk/A.java"));
|
||||
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
|
||||
assert_eq!(v["text"], "项目", "only file: URIs change");
|
||||
// Already encoded: untouched.
|
||||
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
|
||||
encode_loose_file_uris(&mut encoded);
|
||||
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The discovery file, relative to the app data directory.
|
||||
@@ -39,6 +40,9 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Discovery {
|
||||
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
|
||||
#[serde(default = "loopback")]
|
||||
pub address: IpAddr,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
/// The IDE's process.
|
||||
@@ -47,6 +51,10 @@ pub struct Discovery {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
fn loopback() -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
pub fn read(path: &Path) -> io::Result<Self> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
@@ -103,7 +111,13 @@ mod tests {
|
||||
fn round_trips_privately_and_only_its_owner_removes_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = discovery_path(tmp.path());
|
||||
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
|
||||
let ours = Discovery {
|
||||
address: "100.92.72.95".parse().unwrap(),
|
||||
port: 1234,
|
||||
token: "t".into(),
|
||||
pid: std::process::id(),
|
||||
version: "0.1.0".into(),
|
||||
};
|
||||
ours.write(&path).unwrap();
|
||||
assert_eq!(Discovery::read(&path).unwrap(), ours);
|
||||
#[cfg(unix)]
|
||||
@@ -118,6 +132,10 @@ mod tests {
|
||||
ours.write(&path).unwrap();
|
||||
Discovery::remove_if_ours(&path);
|
||||
assert!(!path.exists());
|
||||
|
||||
// The file of an IDE that listened on 127.0.0.1 only has no address.
|
||||
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
|
||||
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! the IDE's built-in tools.
|
||||
//!
|
||||
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
|
||||
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
|
||||
//! clients too) on `127.0.0.1:<random port>` (or another address of
|
||||
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
|
||||
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
|
||||
//! `Authorization: Bearer <token>` (random, per run); requests from another
|
||||
//! `Origin` or `Host` are refused (DNS rebinding).
|
||||
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
|
||||
//! names a folder; the server picks the open project containing it.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
|
||||
//! `version`; readable only by the user) while the server runs.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
|
||||
//! `pid`, `version`; readable only by the user) while the server runs.
|
||||
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
|
||||
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
|
||||
//! persisted client configurations hold no token.
|
||||
@@ -23,7 +24,7 @@ mod server;
|
||||
pub mod stdio;
|
||||
|
||||
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
|
||||
pub use server::{McpServer, ServerOptions};
|
||||
pub use server::{McpServer, ServerOptions, local_address};
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -85,9 +86,15 @@ pub trait ToolRegistry: Send + Sync + 'static {
|
||||
) -> Result<String, ToolCallError>;
|
||||
}
|
||||
|
||||
/// The URL of the server at `port` for `project`.
|
||||
/// The URL of the server at `127.0.0.1:<port>` for `project`.
|
||||
pub fn endpoint_url(port: u16, project: &Path) -> String {
|
||||
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
|
||||
}
|
||||
|
||||
/// The URL of the server at `<address>:<port>` for `project`.
|
||||
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
|
||||
let at = std::net::SocketAddr::new(address, port);
|
||||
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
|
||||
@@ -133,6 +140,9 @@ mod tests {
|
||||
fn urls_and_commands_name_the_project() {
|
||||
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
|
||||
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
|
||||
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
|
||||
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
|
||||
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
|
||||
assert_eq!(
|
||||
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
|
||||
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
|
||||
|
||||
@@ -9,6 +9,7 @@ use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -40,6 +41,8 @@ type Body = Either<Full<Bytes>, EventStream>;
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
token: String,
|
||||
/// The address listened on (`0.0.0.0` / `::`: every address).
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
requests: protocol::Requests,
|
||||
cancel: CancellationToken,
|
||||
@@ -48,6 +51,7 @@ struct Shared {
|
||||
/// The running server. Stopping it (or dropping it) closes the port and
|
||||
/// removes its discovery file.
|
||||
pub struct McpServer {
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
/// Why the port asked for in [`ServerOptions::port`] was not used.
|
||||
port_unavailable: Option<String>,
|
||||
@@ -60,7 +64,10 @@ pub struct McpServer {
|
||||
/// How [`McpServer::start_with`] listens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerOptions {
|
||||
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
|
||||
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
|
||||
pub address: Option<IpAddr>,
|
||||
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
|
||||
/// `None`: a free port.
|
||||
pub port: Option<u16>,
|
||||
@@ -86,15 +93,20 @@ impl McpServer {
|
||||
runtime: &tokio::runtime::Handle,
|
||||
options: ServerOptions,
|
||||
) -> std::io::Result<Self> {
|
||||
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
let bind = |port: u16| {
|
||||
std::net::TcpListener::bind(SocketAddr::new(address, port))
|
||||
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
|
||||
};
|
||||
let (listener, port_unavailable) = match options.port {
|
||||
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Some(port) => match bind(port) {
|
||||
Ok(listener) => (listener, None),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
|
||||
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
|
||||
(bind(0)?, Some(format!("port {port}: {e}")))
|
||||
}
|
||||
},
|
||||
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
|
||||
None => (bind(0)?, None),
|
||||
};
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
@@ -108,6 +120,7 @@ impl McpServer {
|
||||
};
|
||||
if let Some(path) = &discovery {
|
||||
let info = Discovery {
|
||||
address: local_address(address),
|
||||
port,
|
||||
token: token.clone(),
|
||||
pid: std::process::id(),
|
||||
@@ -120,13 +133,19 @@ impl McpServer {
|
||||
let shared = Arc::new(Shared {
|
||||
registry,
|
||||
token: token.clone(),
|
||||
address,
|
||||
port,
|
||||
requests: Default::default(),
|
||||
cancel: cancel.clone(),
|
||||
});
|
||||
runtime.spawn(accept(listener, shared, stopped));
|
||||
tracing::info!("MCP server on 127.0.0.1:{port}");
|
||||
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
|
||||
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
}
|
||||
|
||||
/// The address listened on (`0.0.0.0` / `::`: every address).
|
||||
pub fn address(&self) -> IpAddr {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
@@ -143,14 +162,24 @@ impl McpServer {
|
||||
&self.token
|
||||
}
|
||||
|
||||
/// The endpoint URL for `project`.
|
||||
/// The endpoint URL for `project`, at the address a client on this computer connects to.
|
||||
pub fn url(&self, project: &Path) -> String {
|
||||
crate::endpoint_url(self.port, project)
|
||||
crate::endpoint_url_at(local_address(self.address), self.port, project)
|
||||
}
|
||||
|
||||
pub fn stop(self) {}
|
||||
}
|
||||
|
||||
/// The address a client on this computer connects to for a server listening on `address`:
|
||||
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
|
||||
pub fn local_address(address: IpAddr) -> IpAddr {
|
||||
match address {
|
||||
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
a => a,
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpServer {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.cancel();
|
||||
@@ -212,7 +241,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
if req.uri().path() != "/mcp" {
|
||||
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
|
||||
}
|
||||
if !local_request(req.headers(), shared.port) {
|
||||
if !local_request(req.headers(), shared.address, shared.port) {
|
||||
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
|
||||
}
|
||||
if !authorized(req.headers(), &shared.token) {
|
||||
@@ -343,11 +372,16 @@ impl hyper::body::Body for EventStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// No `Origin` other than this server's own, and a loopback `Host`: a web
|
||||
/// page (DNS rebinding) can't reach the tools even before the token check.
|
||||
fn local_request(headers: &HeaderMap, port: u16) -> bool {
|
||||
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
|
||||
/// (loopback, the address listened on, or any when listening on every address) or as
|
||||
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
|
||||
/// even before the token check.
|
||||
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
|
||||
let ours = |authority: &str| {
|
||||
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
|
||||
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|
||||
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
|
||||
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
|
||||
})
|
||||
};
|
||||
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
|
||||
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
|
||||
@@ -534,7 +568,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
fn only_hosts_and_origins_of_this_server_pass() {
|
||||
let headers = |pairs: &[(header::HeaderName, &str)]| {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
@@ -542,16 +576,43 @@ mod tests {
|
||||
}
|
||||
h
|
||||
};
|
||||
assert!(local_request(&headers(&[]), 80));
|
||||
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
|
||||
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
||||
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
|
||||
assert!(local_request(&headers(&[]), loopback, 80));
|
||||
assert!(host("127.0.0.1:80", loopback));
|
||||
assert!(host("[::1]:80", loopback));
|
||||
assert!(local_request(
|
||||
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
|
||||
loopback,
|
||||
80
|
||||
));
|
||||
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
|
||||
assert!(!host("evil.example:80", loopback));
|
||||
assert!(!host("127.0.0.1:81", loopback));
|
||||
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
|
||||
|
||||
// Listening on one address: that address too, still never a host name.
|
||||
let lan: IpAddr = "100.92.72.95".parse().unwrap();
|
||||
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
|
||||
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
|
||||
// Listening on every address: any IP address, still never a host name.
|
||||
let every: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
|
||||
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
|
||||
assert!(local_request(
|
||||
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
|
||||
every,
|
||||
80
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
|
||||
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
|
||||
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
|
||||
assert_eq!(local_address(ip("::")), ip("::1"));
|
||||
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -148,13 +148,14 @@ async fn post(
|
||||
)
|
||||
};
|
||||
let info = Discovery::read(discovery).map_err(|_| not_running())?;
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
|
||||
let at = std::net::SocketAddr::new(info.address, info.port);
|
||||
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
|
||||
tokio::spawn(conn);
|
||||
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
|
||||
.header(header::HOST, format!("127.0.0.1:{}", info.port))
|
||||
.header(header::HOST, at.to_string())
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
|
||||
.header("mcp-session-id", scope)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
|
||||
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
|
||||
s.server.take().unwrap().stop();
|
||||
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let token = "ab".repeat(32);
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
s.server =
|
||||
@@ -393,3 +393,34 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
|
||||
second.port_unavailable()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
|
||||
let mut s = setup();
|
||||
s.server.take().unwrap().stop();
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
|
||||
// Every address: clients here use loopback; other computers name the server by an address of it.
|
||||
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
|
||||
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
|
||||
let port = s.server().port();
|
||||
assert_eq!(s.server().address().to_string(), "0.0.0.0");
|
||||
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
|
||||
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
|
||||
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
|
||||
let request = |host: String| {
|
||||
format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
|
||||
bearer(&s),
|
||||
ping.len()
|
||||
)
|
||||
};
|
||||
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
|
||||
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
|
||||
|
||||
// Not an address of this computer: the server does not start, and says which address.
|
||||
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
|
||||
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
|
||||
assert!(error.to_string().contains("192.0.2.1"), "{error}");
|
||||
}
|
||||
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.7";
|
||||
pub const API_VERSION: &str = "1.8";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -234,6 +234,11 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (path, set, item);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Replace all `(path, icon)` entries of a tree decoration set at once (API 1.8).
|
||||
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
let _ = (set, items);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Trusted repository history.
|
||||
fn vcs_log(
|
||||
&self,
|
||||
@@ -1058,6 +1063,9 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
}),
|
||||
WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p),
|
||||
WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p),
|
||||
WorkbenchEvent::ProblemsChanged => {
|
||||
wit_events::Event::Other(wit_types::Extension { kind: "problems-changed".into(), data: "{}".into() })
|
||||
}
|
||||
WorkbenchEvent::Scheduled => wit_events::Event::Scheduled,
|
||||
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
|
||||
action: a.action,
|
||||
|
||||
@@ -151,6 +151,67 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an already-open document for a background edit without following a
|
||||
/// symbolic link, junction, or other Windows reparse point. Unlike ordinary
|
||||
/// project reads, background edits may have originated with another machine:
|
||||
/// accepting a link would let that machine change an unexpected file when the
|
||||
/// user later saves the editor buffer.
|
||||
///
|
||||
/// The input is deliberately project-relative. Existing path components are
|
||||
/// examined with `symlink_metadata` from the canonical project root; no
|
||||
/// canonicalization happens after the walk, so a link is never traversed.
|
||||
pub fn resolve_background_document_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() || Path::new(path).is_absolute() {
|
||||
return Err(format!("{path}: background document paths must be project-relative"));
|
||||
}
|
||||
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let components: Vec<_> = Path::new(path).components().collect();
|
||||
if components.is_empty() || components.iter().any(|part| !matches!(part, std::path::Component::Normal(_))) {
|
||||
return Err(format!("{path}: background document paths must not leave the project"));
|
||||
}
|
||||
let mut current = root.clone();
|
||||
for (index, part) in components.iter().enumerate() {
|
||||
let std::path::Component::Normal(name) = part else { unreachable!() };
|
||||
if background_control_name(name) {
|
||||
return Err(format!("{path}: background edits may not target VCS or IDE control files"));
|
||||
}
|
||||
current.push(name);
|
||||
let metadata = std::fs::symlink_metadata(¤t).map_err(|_| format!("{path}: no such open document"))?;
|
||||
if is_link_or_reparse(&metadata) {
|
||||
return Err(format!("{path}: background edits refuse symbolic links"));
|
||||
}
|
||||
if index + 1 < components.len() && !metadata.is_dir() {
|
||||
return Err(format!("{path}: not a directory"));
|
||||
}
|
||||
if index + 1 == components.len() && !metadata.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// [`resolve_background_document_in_root`] over a workspace group's roots.
|
||||
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
|
||||
first_root_resolving(roots, path, resolve_background_document_in_root)
|
||||
}
|
||||
|
||||
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
let reparse = {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let reparse = false;
|
||||
metadata.file_type().is_symlink() || reparse
|
||||
}
|
||||
|
||||
fn background_control_name(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
|
||||
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
|
||||
}
|
||||
|
||||
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
|
||||
/// without requiring it to exist: `Ok(true)` also for a missing path, so
|
||||
/// callers distinguish "outside" from "absent". Existing symlinks are
|
||||
@@ -705,6 +766,25 @@ mod tests {
|
||||
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_document_edits_are_relative_and_exclude_control_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(root.join(".openidea/state"), "state\n").unwrap();
|
||||
|
||||
assert!(resolve_background_document_in_root(&root, "src/main.rs").is_ok());
|
||||
for path in
|
||||
["/etc/passwd", "../outside", "src/../.git/hooks/pre-commit", ".git/hooks/pre-commit", ".openidea/state"]
|
||||
{
|
||||
assert!(resolve_background_document_in_root(&root, path).is_err(), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
|
||||
for path in [
|
||||
@@ -740,6 +820,23 @@ mod tests {
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn background_document_edits_refuse_final_and_ancestor_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
symlink(root.join("src/main.rs"), root.join("final-link.rs")).unwrap();
|
||||
symlink(root.join("src"), root.join("linked-src")).unwrap();
|
||||
|
||||
assert!(resolve_background_document_in_root(&root, "final-link.rs").unwrap_err().contains("symbolic links"));
|
||||
assert!(
|
||||
resolve_background_document_in_root(&root, "linked-src/main.rs").unwrap_err().contains("symbolic links")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_paths_follow_existing_and_dangling_symlinks() {
|
||||
|
||||
@@ -119,11 +119,13 @@ pub enum EventKind {
|
||||
EditorFocused,
|
||||
ActionPerformed,
|
||||
VcsChanged,
|
||||
/// Problems of the window's files changed (a language server or a report).
|
||||
ProblemsChanged,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 16] = [
|
||||
pub const ALL: [Self; 17] = [
|
||||
Self::DocumentChanged,
|
||||
Self::WebsocketReady,
|
||||
Self::FileClosed,
|
||||
@@ -139,6 +141,7 @@ impl EventKind {
|
||||
Self::EditorFocused,
|
||||
Self::ActionPerformed,
|
||||
Self::VcsChanged,
|
||||
Self::ProblemsChanged,
|
||||
Self::Scheduled,
|
||||
];
|
||||
|
||||
@@ -159,6 +162,7 @@ impl EventKind {
|
||||
Self::EditorFocused => "editor-focused",
|
||||
Self::ActionPerformed => "action-performed",
|
||||
Self::VcsChanged => "vcs-changed",
|
||||
Self::ProblemsChanged => "problems-changed",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
@@ -294,6 +298,8 @@ pub enum WorkbenchEvent {
|
||||
EditorFocused(Option<String>),
|
||||
ActionPerformed(ActionPerformed),
|
||||
VcsChanged(String),
|
||||
/// Problems of the window's files changed; plugins re-read `problems`.
|
||||
ProblemsChanged,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
@@ -315,6 +321,7 @@ impl WorkbenchEvent {
|
||||
Self::EditorFocused(_) => EventKind::EditorFocused,
|
||||
Self::ActionPerformed(_) => EventKind::ActionPerformed,
|
||||
Self::VcsChanged(_) => EventKind::VcsChanged,
|
||||
Self::ProblemsChanged => EventKind::ProblemsChanged,
|
||||
Self::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
@@ -925,6 +932,9 @@ pub struct FileDecoration {
|
||||
/// Text shown after the name (API 1.7 `set-file-note`).
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
/// `[[contributes.fileIcons]]` id for this file or folder (API 1.8 `set-file-icon`).
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
/// Commit metadata, date in Unix seconds.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
|
||||
return Ok(Err(wit::Error::Invalid("edits too large".into())));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
let path = match self.resolve_in_project(&root, &path) {
|
||||
let path = match self.resolve_background_document_in_project(&root, &path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
|
||||
};
|
||||
@@ -936,6 +936,14 @@ impl StoreData {
|
||||
sandbox::resolve_writable_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
|
||||
})
|
||||
}
|
||||
/// A background document edit is the only plugin operation that changes an
|
||||
/// editor on behalf of a remote peer. It therefore rejects links and IDE
|
||||
/// control directories instead of using the normal read resolver.
|
||||
fn resolve_background_document_in_project(&self, root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
sandbox::resolve_background_document_in_root(root, path).or_else(|root_error| {
|
||||
sandbox::resolve_background_document_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
|
||||
})
|
||||
}
|
||||
/// Whether a `project` resource's path lands inside the resource's root or
|
||||
/// any member of its workspace group, without requiring it to exist.
|
||||
fn inside_project(&self, root: &Path, path: &str) -> Result<bool, String> {
|
||||
@@ -2497,7 +2505,13 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
.file_decorations(
|
||||
Some(&real),
|
||||
&set_id,
|
||||
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
|
||||
Some(crate::FileDecoration {
|
||||
badge: item.badge,
|
||||
style: item.style,
|
||||
tooltip: item.tooltip,
|
||||
note: None,
|
||||
icon: None,
|
||||
}),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
@@ -2515,9 +2529,36 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
fn set_file_icon(&mut self, path: String, set_id: String, icon: String, tooltip: Option<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let real = match self.decoration_path(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: None, icon: Some(icon) };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
fn set_file_icons(&mut self, set_id: String, items: Vec<(String, String)>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if items.len() > 10_000 {
|
||||
return Ok(Err(wit::Error::Invalid("at most 10000 file icons per set".into())));
|
||||
}
|
||||
let mut resolved = Vec::with_capacity(items.len());
|
||||
for (path, icon) in items {
|
||||
match self.decoration_path(&path) {
|
||||
Ok(real) => resolved.push((real, icon)),
|
||||
Err(e) => return Ok(Err(e)),
|
||||
}
|
||||
}
|
||||
Ok(self.host.file_icon_decorations(&set_id, resolved).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod language_features_bindings {
|
||||
|
||||
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.7.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.0.0-rc.1"] {
|
||||
let host = v("1.8.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.8.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.9") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.7 SDK"), "{older}");
|
||||
assert!(older.contains("rebuild it with the 1.8 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
|
||||
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
|
||||
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
|
||||
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
|
||||
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
|
||||
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
|
||||
@@ -21,6 +21,27 @@ impl Plugin for Fixture {
|
||||
&[Diagnostic::new(Range::default(), Severity::Info, "Manual problem")],
|
||||
)
|
||||
.map(|()| None),
|
||||
// The host-owned snapshot, one entry per producer: what `problems` reads.
|
||||
"problems" => {
|
||||
let files: Vec<String> = host::problems(None)?
|
||||
.into_iter()
|
||||
.map(|f| format!("{}:{}:{}", f.path, f.source, f.diagnostics.len()))
|
||||
.collect();
|
||||
Ok(Some(files.join("|")))
|
||||
}
|
||||
// A line-end annotation in the severity styles the editor draws: the host must
|
||||
// keep `typo` and `weak` instead of dropping them as unknown.
|
||||
"decorate" => {
|
||||
let path = arg.unwrap_or_default();
|
||||
let item = |style: &str, after: &str| openidea_plugin::Decoration {
|
||||
range: Range::default(),
|
||||
style: style.into(),
|
||||
hover: None,
|
||||
after: Some(after.into()),
|
||||
};
|
||||
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 @@
|
||||
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
|
||||
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
|
||||
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
|
||||
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
|
||||
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
|
||||
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
|
||||
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
|
||||
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
|
||||
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
|
||||
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
|
||||
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
|
||||
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
|
||||
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
|
||||
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
|
||||
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
|
||||
+3
-3
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.7.0;
|
||||
export openidea:plugin/providers@1.7.0;
|
||||
export openidea:plugin/file-actions@1.7.0;
|
||||
include openidea:plugin/plugin-syntax@1.8.0;
|
||||
export openidea:plugin/providers@1.8.0;
|
||||
export openidea:plugin/file-actions@1.8.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
|
||||
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
|
||||
d11f9cb1130f3f3aaa89514bf47ec8ece9b2ca0edf1d82c11f80b262325f16be
|
||||
Loaded 100 of 552 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user