дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-14 13:45:49 +02:00
Порівняти коміти
105
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
b83df2dbda | ||
|
|
9f7a35f170 | ||
|
|
52b3e14a8c | ||
|
|
934e4ca0e7 | ||
|
|
a2809bda80 | ||
|
|
0fd8389b33 | ||
|
|
19c0ae07f2 | ||
|
|
5a5b53d86b | ||
|
|
ac7a81db1c | ||
|
|
9681e1a118 | ||
|
|
293972baaa | ||
|
|
b173a53004 | ||
|
|
f6e54f581b | ||
|
|
276a30849c | ||
|
|
63abedaadd | ||
|
|
c22a7fa112 | ||
|
|
a46f187428 | ||
|
|
ff826279f2 | ||
|
|
759fb3278d | ||
|
|
f7d19e1711 | ||
|
|
97d202ade0 | ||
|
|
f0f15366e4 | ||
|
|
fecbf8ed14 | ||
|
|
2d00f09393 | ||
|
|
f154bf8c83 | ||
|
|
d3720c0e11 | ||
|
|
ec862b3668 | ||
|
|
735830d642 | ||
|
|
65c0413dbb | ||
|
|
14bb7bcc6f | ||
|
|
8113d101ef | ||
|
|
5f7e05adc4 | ||
|
|
f2b85eca4c | ||
|
|
e41cf32a94 | ||
|
|
0cd19a8c0f | ||
|
|
fb76a6dab7 | ||
|
|
f4502ffffe | ||
|
|
2430c892ba | ||
|
|
8876d04cb8 | ||
|
|
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 |
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,6 +291,56 @@ pub enum DebugEvent {
|
||||
JvmBackend {
|
||||
state: JvmBackendState,
|
||||
},
|
||||
/// A step in the life of one request of a debugged page.
|
||||
JsNetwork {
|
||||
event: JsNetworkEvent,
|
||||
},
|
||||
/// The adapter does not report the requests of this page: there will be no `JsNetwork`.
|
||||
JsNetworkUnavailable,
|
||||
}
|
||||
|
||||
/// Which step of a request a [`JsNetworkEvent`] reports.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JsNetworkKind {
|
||||
Request,
|
||||
Response,
|
||||
Finished,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The script frame that made a request, where the browser ran it (0-based line and column).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsInitiator {
|
||||
pub function: String,
|
||||
pub url: String,
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
/// What a debugged page's browser reports about a request, reduced to what the Network view
|
||||
/// shows. Events of one request share `request_id`; each kind fills its own fields.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsNetworkEvent {
|
||||
pub kind: JsNetworkKind,
|
||||
pub request_id: String,
|
||||
/// The browser's monotonic clock, comparable between the events of a page.
|
||||
pub timestamp_ms: u64,
|
||||
/// Request and response.
|
||||
pub url: Option<String>,
|
||||
pub method: Option<String>,
|
||||
/// `Fetch`, `XHR`, `Script`, `Document`, …
|
||||
pub resource_type: Option<String>,
|
||||
pub status: Option<u16>,
|
||||
pub mime_type: Option<String>,
|
||||
/// The request's headers, or the response's.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Bytes received over the network, once finished.
|
||||
pub encoded_length: Option<u64>,
|
||||
pub error_text: Option<String>,
|
||||
pub initiator: Option<JsInitiator>,
|
||||
}
|
||||
|
||||
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
|
||||
|
||||
@@ -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,
|
||||
@@ -1153,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)]
|
||||
@@ -1349,11 +1358,15 @@ pub struct TypeScriptSettings {
|
||||
/// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
|
||||
/// or a path to a TypeScript `lib` directory.
|
||||
pub version: String,
|
||||
/// tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
|
||||
/// plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
|
||||
/// vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
|
||||
pub tsserver_max_memory: u32,
|
||||
}
|
||||
|
||||
impl Default for TypeScriptSettings {
|
||||
fn default() -> Self {
|
||||
Self { version: "bundled".into() }
|
||||
Self { version: "bundled".into(), tsserver_max_memory: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,12 +266,16 @@ pub struct DebuggerSettings {
|
||||
pub inline_values: bool,
|
||||
/// Data Views: list variables by name instead of the adapter's order.
|
||||
pub sort_values: bool,
|
||||
/// Data Views: evaluate watches while the program runs, where the debugger can (JavaScript).
|
||||
pub live_watches: bool,
|
||||
pub value_tooltip: bool,
|
||||
/// While suspended, selecting an expression shows its value.
|
||||
pub value_tooltip_on_selection: bool,
|
||||
pub value_tooltip_delay: u32,
|
||||
/// Stepping: do not step into library code (debugpy `justMyCode`, js-debug `skipFiles`).
|
||||
pub skip_library_code: bool,
|
||||
/// JavaScript: debugging a dev server (`vite`, `next dev`) also opens its page in a browser session.
|
||||
pub debug_server_page: bool,
|
||||
pub python: PythonDebuggerSettings,
|
||||
}
|
||||
|
||||
@@ -285,10 +289,12 @@ impl Default for DebuggerSettings {
|
||||
run_to_cursor_inlay: true,
|
||||
inline_values: true,
|
||||
sort_values: false,
|
||||
live_watches: false,
|
||||
value_tooltip: true,
|
||||
value_tooltip_on_selection: true,
|
||||
value_tooltip_delay: 700,
|
||||
skip_library_code: true,
|
||||
debug_server_page: false,
|
||||
python: PythonDebuggerSettings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Requests of vscode-js-debug's own, beyond the Debug Adapter Protocol: what a page in a
|
||||
//! browser offers a debugger besides stepping (breakpoints on DOM events and requests).
|
||||
//! Each is a typed method; a js-debug build that lacks one answers with an error, which is
|
||||
//! reported as the feature being unavailable.
|
||||
|
||||
use crate::session::Session;
|
||||
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT};
|
||||
use ide_core::event::{JsInitiator, JsNetworkEvent, JsNetworkKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const ADAPTER: &str = "js-debug";
|
||||
|
||||
/// Whether `session` debugs a page: the child js-debug starts for a browser launch or attach.
|
||||
/// The launch's own session only owns the browser.
|
||||
pub(crate) fn is_page(session: &Session) -> bool {
|
||||
session.adapter == ADAPTER
|
||||
&& session.parent.is_some()
|
||||
&& matches!(session.launch_type.get().map(String::as_str), Some("pwa-chrome" | "pwa-msedge"))
|
||||
}
|
||||
|
||||
/// Waits until the adapter answered the `launch` of `session`. js-debug sets a page's session
|
||||
/// up between `configurationDone` and that answer; a request of its own that arrives then is
|
||||
/// answered and has no effect.
|
||||
async fn launched(session: &Session) -> bool {
|
||||
let mut launched = session.launched.subscribe();
|
||||
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
|
||||
matches!(answered, Ok(Ok(_)))
|
||||
}
|
||||
|
||||
impl DebugManager {
|
||||
/// One of js-debug's requests for pages, sent to the page `s` once its session has started.
|
||||
async fn js_page_request(&self, s: DebugSessionId, command: &str, arguments: Value) -> IdeResult<Value> {
|
||||
let session = self.session(s)?;
|
||||
if !is_page(&session) {
|
||||
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
|
||||
}
|
||||
if !launched(&session).await {
|
||||
return Err(IdeError::unavailable("the page's debug session did not finish starting"));
|
||||
}
|
||||
session
|
||||
.conn
|
||||
.request_timeout(command, arguments, REQUEST_TIMEOUT)
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(format!("the JavaScript debugger refused `{command}`: {e}")))
|
||||
}
|
||||
|
||||
/// Breakpoints on events (`listener:click`, `instrumentation:setTimeout`) and on requests
|
||||
/// whose URL contains one of `xhr`. The lists replace the page's previous ones.
|
||||
pub async fn js_set_custom_breakpoints(
|
||||
&self,
|
||||
s: DebugSessionId,
|
||||
ids: Vec<String>,
|
||||
xhr: Vec<String>,
|
||||
) -> IdeResult<()> {
|
||||
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
|
||||
pub(crate) const NETWORK_EVENTS: [&str; 4] =
|
||||
["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"];
|
||||
|
||||
/// Response bodies shown are cut at this many bytes.
|
||||
pub const RESPONSE_BODY_LIMIT: usize = 1_048_576;
|
||||
|
||||
/// The body of a response, as text or as the browser's base64 for binary content.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsResponseBody {
|
||||
pub body: String,
|
||||
pub base64_encoded: bool,
|
||||
/// The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
fn text(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
fn headers(v: &Value) -> BTreeMap<String, String> {
|
||||
v.get("headers")
|
||||
.and_then(Value::as_object)
|
||||
.map(|h| h.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The frame a request is attributed to: the innermost one outside `node_modules`, looked
|
||||
/// for through the stacks that scheduled this one (an `await`, a timer), since a request sent
|
||||
/// through a library has the library innermost. A stack of library frames only gives its first.
|
||||
fn initiator(data: &Value) -> Option<JsInitiator> {
|
||||
let stack = data.pointer("/initiator/stack")?;
|
||||
let frames = |stack: &Value| stack.get("callFrames").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let stacks = std::iter::successors(Some(stack), |stack| stack.get("parent")).take(32);
|
||||
let all: Vec<Value> = stacks.flat_map(frames).collect();
|
||||
let own = |frame: &&Value| {
|
||||
frame.get("url").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && !url.contains("/node_modules/"))
|
||||
};
|
||||
let frame = all.iter().find(own).or(all.first())?;
|
||||
let number = |key: &str| frame.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0);
|
||||
Some(JsInitiator {
|
||||
function: text(frame, "functionName").unwrap_or_default(),
|
||||
url: text(frame, "url")?,
|
||||
line: number("lineNumber"),
|
||||
column: number("columnNumber"),
|
||||
})
|
||||
}
|
||||
|
||||
/// js-debug's `networkEvent` (`{event, data}`, `data` being the browser's own parameters of
|
||||
/// that event) as a [`JsNetworkEvent`]; `None` for events the Network view has no use for.
|
||||
pub(crate) fn network_event(body: &Value) -> Option<JsNetworkEvent> {
|
||||
let data = body.get("data")?;
|
||||
let kind = match body.get("event").and_then(Value::as_str)? {
|
||||
"requestWillBeSent" => JsNetworkKind::Request,
|
||||
"responseReceived" => JsNetworkKind::Response,
|
||||
"loadingFinished" => JsNetworkKind::Finished,
|
||||
"loadingFailed" => JsNetworkKind::Failed,
|
||||
_ => return None,
|
||||
};
|
||||
let part = match kind {
|
||||
JsNetworkKind::Request => data.get("request"),
|
||||
JsNetworkKind::Response => data.get("response"),
|
||||
_ => None,
|
||||
};
|
||||
let part = part.unwrap_or(&Value::Null);
|
||||
Some(JsNetworkEvent {
|
||||
kind,
|
||||
request_id: text(data, "requestId")?,
|
||||
// Seconds with a fraction.
|
||||
timestamp_ms: (data.get("timestamp").and_then(Value::as_f64).unwrap_or(0.0).max(0.0) * 1000.0).round() as u64,
|
||||
url: text(part, "url"),
|
||||
method: text(part, "method"),
|
||||
resource_type: text(data, "type"),
|
||||
status: part.get("status").and_then(Value::as_u64).and_then(|s| u16::try_from(s).ok()),
|
||||
mime_type: text(part, "mimeType"),
|
||||
headers: headers(part),
|
||||
encoded_length: data.get("encodedDataLength").and_then(Value::as_f64).map(|n| n.max(0.0) as u64),
|
||||
error_text: text(data, "errorText"),
|
||||
initiator: if kind == JsNetworkKind::Request { initiator(data) } else { None },
|
||||
})
|
||||
}
|
||||
|
||||
/// `networkCall getResponseBody`'s answer, cut at `limit` bytes on a character boundary.
|
||||
fn response_body(answer: &Value, limit: usize) -> JsResponseBody {
|
||||
let mut body = text(answer, "body").unwrap_or_default();
|
||||
let truncated = body.len() > limit;
|
||||
if truncated {
|
||||
let mut end = limit;
|
||||
while !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
body.truncate(end);
|
||||
}
|
||||
JsResponseBody {
|
||||
body,
|
||||
base64_encoded: answer.get("base64Encoded").and_then(Value::as_bool).unwrap_or(false),
|
||||
truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/// The arguments of `enableNetworking`, which has a page report its requests
|
||||
/// ([`ide_core::event::DebugEvent::JsNetwork`]). A page session sends it as it starts.
|
||||
pub(crate) fn networking_arguments() -> Value {
|
||||
json!({ "mirrorEvents": NETWORK_EVENTS })
|
||||
}
|
||||
|
||||
impl DebugManager {
|
||||
/// The body of the response to `request_id`, while the browser still has it.
|
||||
pub async fn js_response_body(&self, s: DebugSessionId, request_id: String) -> IdeResult<JsResponseBody> {
|
||||
let call = json!({ "method": "getResponseBody", "params": { "requestId": request_id } });
|
||||
let answer = self.js_page_request(s, "networkCall", call).await?;
|
||||
Ok(response_body(&answer, RESPONSE_BODY_LIMIT))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn event(kind: &str, data: Value) -> Option<ide_core::event::JsNetworkEvent> {
|
||||
network_event(&json!({ "event": kind, "data": data }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_carries_what_was_asked_and_who_asked() {
|
||||
let e = event(
|
||||
"requestWillBeSent",
|
||||
json!({
|
||||
"requestId": "76695.31", "timestamp": 611807.5, "type": "Fetch",
|
||||
"request": { "method": "GET", "url": "http://127.0.0.1:5195/src/main.ts", "headers": { "Accept": "*/*", "X-N": 1 } },
|
||||
"initiator": { "type": "script", "stack": { "callFrames": [
|
||||
{ "functionName": "load", "url": "http://127.0.0.1:5195/src/App.vue", "lineNumber": 9, "columnNumber": 2 }
|
||||
] } }
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(e.kind, JsNetworkKind::Request);
|
||||
assert_eq!((e.request_id.as_str(), e.timestamp_ms), ("76695.31", 611_807_500));
|
||||
assert_eq!(e.url.as_deref(), Some("http://127.0.0.1:5195/src/main.ts"));
|
||||
assert_eq!((e.method.as_deref(), e.resource_type.as_deref()), (Some("GET"), Some("Fetch")));
|
||||
// Only text headers are kept.
|
||||
assert_eq!(e.headers.len(), 1);
|
||||
assert_eq!(e.headers["Accept"], "*/*");
|
||||
let from = e.initiator.unwrap();
|
||||
assert_eq!((from.function.as_str(), from.line, from.column), ("load", 9, 2));
|
||||
assert_eq!(from.url, "http://127.0.0.1:5195/src/App.vue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_made_through_a_library_is_asked_by_the_code_that_called_the_library() {
|
||||
let frame = |function: &str, url: &str| json!({ "functionName": function, "url": url, "lineNumber": 1, "columnNumber": 0 });
|
||||
let lib = "http://localhost:5173/node_modules/.vite/deps/http-client.js";
|
||||
let asked_by = |stack: Value| {
|
||||
let data = json!({ "requestId": "1", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/api" },
|
||||
"initiator": { "type": "script", "stack": stack } });
|
||||
event("requestWillBeSent", data).unwrap().initiator.unwrap().function
|
||||
};
|
||||
// The innermost frame is the library's; the project's caller is further out.
|
||||
let direct = json!({ "callFrames": [frame("dispatch", lib), frame("request", lib), frame("loadItems", "http://localhost:5173/src/api.ts")] });
|
||||
assert_eq!(asked_by(direct), "loadItems");
|
||||
// Across an `await`, the caller is in the stack that scheduled this one.
|
||||
let scheduled = json!({ "callFrames": [frame("dispatch", lib)],
|
||||
"parent": { "callFrames": [frame("then", lib)], "parent": { "callFrames": [frame("onMounted", "http://localhost:5173/src/App.vue")] } } });
|
||||
assert_eq!(asked_by(scheduled), "onMounted");
|
||||
// A library on its own: the frame that sent the request.
|
||||
assert_eq!(asked_by(json!({ "callFrames": [frame("dispatch", lib), frame("poll", lib)] })), "dispatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_a_finish_and_a_failure_carry_their_outcome() {
|
||||
let response = event(
|
||||
"responseReceived",
|
||||
json!({ "requestId": "7", "timestamp": 2.0, "type": "Script", "response": {
|
||||
"url": "http://localhost/a.js", "status": 200, "mimeType": "text/javascript",
|
||||
"headers": { "Content-Type": "text/javascript" }
|
||||
} }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.kind, JsNetworkKind::Response);
|
||||
assert_eq!((response.status, response.mime_type.as_deref()), (Some(200), Some("text/javascript")));
|
||||
assert_eq!(response.headers["Content-Type"], "text/javascript");
|
||||
|
||||
let finished =
|
||||
event("loadingFinished", json!({ "requestId": "7", "timestamp": 2.5, "encodedDataLength": 1234 })).unwrap();
|
||||
assert_eq!((finished.kind, finished.encoded_length), (JsNetworkKind::Finished, Some(1234)));
|
||||
|
||||
let failed =
|
||||
event("loadingFailed", json!({ "requestId": "8", "timestamp": 3.0, "errorText": "net::ERR_FAILED" }))
|
||||
.unwrap();
|
||||
assert_eq!((failed.kind, failed.error_text.as_deref()), (JsNetworkKind::Failed, Some("net::ERR_FAILED")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_that_name_no_request_or_are_not_mirrored_are_dropped() {
|
||||
assert!(event("requestWillBeSent", json!({ "timestamp": 1.0 })).is_none());
|
||||
assert!(event("dataReceived", json!({ "requestId": "7", "timestamp": 1.0 })).is_none());
|
||||
assert!(network_event(&json!({ "data": { "requestId": "7" } })).is_none());
|
||||
// A request made by the page's parser has no script behind it.
|
||||
let parser = event(
|
||||
"requestWillBeSent",
|
||||
json!({ "requestId": "9", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/" },
|
||||
"initiator": { "type": "parser" } }),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(parser.initiator.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_body_is_cut_at_the_limit_on_a_character() {
|
||||
let whole = response_body(&json!({ "body": "héllo", "base64Encoded": false }), 100);
|
||||
assert_eq!((whole.body.as_str(), whole.base64_encoded, whole.truncated), ("héllo", false, false));
|
||||
// `é` is two bytes: a cut inside it backs off to the character before.
|
||||
let cut = response_body(&json!({ "body": "héllo" }), 2);
|
||||
assert_eq!((cut.body.as_str(), cut.truncated), ("h", true));
|
||||
let binary = response_body(&json!({ "body": "AAEC", "base64Encoded": true }), 100);
|
||||
assert!(binary.base64_encoded && !binary.truncated);
|
||||
assert_eq!(response_body(&json!({}), 100).body, "");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod js_debug;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
pub mod kotlin_lsp;
|
||||
@@ -23,6 +24,7 @@ use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
pub use js_debug::JsResponseBody;
|
||||
pub use jvm_memory::*;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -136,6 +138,11 @@ pub struct DebugCapabilities {
|
||||
pub jvm_instances: bool,
|
||||
/// The session can be asked for a heap dump ([`DebugManager::jvm_heap_dump`]).
|
||||
pub jvm_heap: bool,
|
||||
/// The session debugs a page in a browser: js-debug's requests for pages apply
|
||||
/// ([`DebugManager::js_set_custom_breakpoints`]).
|
||||
pub js_page: bool,
|
||||
/// The adapter evaluates expressions while the program runs, without a stack frame.
|
||||
pub live_evaluate: bool,
|
||||
}
|
||||
|
||||
/// Result of [`DebugManager::redefine_classes`].
|
||||
@@ -393,6 +400,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
|
||||
@@ -580,6 +597,8 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
|
||||
jvm_memory: false,
|
||||
jvm_instances: false,
|
||||
jvm_heap: false,
|
||||
js_page: false,
|
||||
live_evaluate: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +608,8 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
|
||||
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.jvm_instances = session.jvm_inspector.lock().is_some();
|
||||
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.js_page = js_debug::is_page(session);
|
||||
caps.live_evaluate = session.adapter == js_debug::ADAPTER;
|
||||
caps
|
||||
}
|
||||
|
||||
@@ -744,6 +765,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 +896,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());
|
||||
@@ -921,6 +944,9 @@ impl DebugManager {
|
||||
.flatten();
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
if let Some(launch_type) = arguments.get("type").and_then(Value::as_str) {
|
||||
let _ = session.launch_type.set(launch_type.to_owned());
|
||||
}
|
||||
// rdbg execs the debuggee in its own process and sends no DAP process event.
|
||||
if let Some(pid) = rdbg_pid {
|
||||
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
|
||||
@@ -1155,6 +1181,22 @@ impl DebugManager {
|
||||
reply.await.unwrap_or(Err(DapError::Closed))?;
|
||||
}
|
||||
}
|
||||
s.launched.send_replace(true);
|
||||
// A page reports its requests from here on. This is the earliest js-debug takes the
|
||||
// request (before its answer to `launch` the page has no adapter and the request is
|
||||
// never answered), and the page is already loading: its document and first scripts
|
||||
// are not in the list.
|
||||
if js_debug::is_page(s) {
|
||||
let answer = s.conn.send_request("enableNetworking", js_debug::networking_arguments())?;
|
||||
let session = s.clone();
|
||||
// Not waited for here: the page runs on. A refusal is told to the Network view,
|
||||
// which would otherwise wait for requests that never come.
|
||||
tokio::spawn(async move {
|
||||
if matches!(answer.await, Ok(Err(_))) {
|
||||
session.emit(ide_core::event::DebugEvent::JsNetworkUnavailable);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(link) = s.jdwp_link.lock().as_ref() {
|
||||
link.release();
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ pub(crate) struct Session {
|
||||
pub job: Mutex<Option<ide_core::process::Job>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
/// Set once the adapter answered `launch` / `attach`, after the configuration.
|
||||
pub launched: watch::Sender<bool>,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
pub startup_cancel: ide_core::CancellationToken,
|
||||
pub output_drained: watch::Sender<bool>,
|
||||
@@ -89,6 +91,9 @@ pub(crate) struct Session {
|
||||
progress: Mutex<HashMap<String, TaskId>>,
|
||||
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
|
||||
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
|
||||
/// The `type` of the launch or attach request (js-debug's `pwa-node`, `pwa-chrome`); a child
|
||||
/// session's comes from the configuration the adapter started it with.
|
||||
pub launch_type: std::sync::OnceLock<String>,
|
||||
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
|
||||
/// One inspector request per session at a time.
|
||||
pub inspect_lock: tokio::sync::Mutex<()>,
|
||||
@@ -230,6 +235,7 @@ impl Session {
|
||||
#[cfg(windows)]
|
||||
job: Mutex::new(job),
|
||||
configured: AtomicBool::new(false),
|
||||
launched: watch::channel(false).0,
|
||||
initialized: watch::channel(false).0,
|
||||
startup_cancel: ide_core::CancellationToken::new(),
|
||||
output_drained: watch::channel(true).0,
|
||||
@@ -248,6 +254,7 @@ impl Session {
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
jdwp_link: parking_lot::Mutex::new(None),
|
||||
launch_type: std::sync::OnceLock::new(),
|
||||
jvm_inspector: parking_lot::Mutex::new(None),
|
||||
inspect_lock: tokio::sync::Mutex::default(),
|
||||
ended: ide_core::CancellationToken::new(),
|
||||
@@ -832,6 +839,12 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
// js-debug, after `enableNetworking`.
|
||||
"networkEvent" => {
|
||||
if let Some(event) = crate::js_debug::network_event(&body) {
|
||||
s.emit(DebugEvent::JsNetwork { event });
|
||||
}
|
||||
}
|
||||
_ => tracing::trace!("dap: ignored event {event}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ struct Script {
|
||||
delay_initialize: Option<Duration>,
|
||||
initialized_delay: Duration,
|
||||
launch_delay: Duration,
|
||||
/// Answer a child session's `launch` this long after its `configurationDone`, reading
|
||||
/// requests meanwhile. Like js-debug, one of `accept` that arrives in between is answered
|
||||
/// and has no effect: the log gets a `dropped` entry for it.
|
||||
child_launch_delay: Duration,
|
||||
/// Leave startup pending before `initialized` (false) or after configuration (true).
|
||||
pending_startup: Option<bool>,
|
||||
startup_error: Option<&'static str>,
|
||||
@@ -154,8 +158,15 @@ struct Script {
|
||||
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
|
||||
gdb15_starts_at_launch: bool,
|
||||
launch_after_configuration: bool,
|
||||
/// Requests of the adapter's own (js-debug's `setCustomBreakpoints`…) answered with an empty body.
|
||||
accept: &'static [&'static str],
|
||||
/// Requests of the adapter's own answered with a body and followed by events.
|
||||
custom: Option<fn(&Value) -> Option<CustomReply>>,
|
||||
}
|
||||
|
||||
/// The answer to a request of the adapter's own: the response body, then events (name, body).
|
||||
type CustomReply = (Value, Vec<(&'static str, Value)>);
|
||||
|
||||
impl Default for Script {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -181,11 +192,14 @@ impl Default for Script {
|
||||
delay_initialize: None,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
child_launch_delay: Duration::ZERO,
|
||||
pending_startup: None,
|
||||
startup_error: None,
|
||||
close_during_startup: false,
|
||||
gdb15_starts_at_launch: false,
|
||||
launch_after_configuration: false,
|
||||
accept: &[],
|
||||
custom: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,10 +232,15 @@ impl AdapterLauncher for FakeLauncher {
|
||||
}
|
||||
|
||||
async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream: S, log: Log, script: Script) {
|
||||
let (r, mut w) = tokio::io::split(stream);
|
||||
let (r, w) = tokio::io::split(stream);
|
||||
// Shared with the task that answers a child's `launch` late (`child_launch_delay`).
|
||||
let w = Arc::new(tokio::sync::Mutex::new(w));
|
||||
let mut r = BufReader::new(r);
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut child = false;
|
||||
// Between `configurationDone` and the answer to `launch`.
|
||||
let starting = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
let mut stopped = false;
|
||||
let mut running = false;
|
||||
@@ -230,7 +249,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut v: Value = $v;
|
||||
seq += 1;
|
||||
v["seq"] = seq.into();
|
||||
framing::write_message(&mut w, &serde_json::to_vec(&v).unwrap()).await.unwrap();
|
||||
framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&v).unwrap()).await.unwrap();
|
||||
}};
|
||||
}
|
||||
macro_rules! ok {
|
||||
@@ -292,6 +311,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
continue;
|
||||
}
|
||||
pending_launch = msg["seq"].as_i64();
|
||||
child = msg["arguments"]["isChild"] == true;
|
||||
if let Some(args) = &script.run_in_terminal {
|
||||
send!(
|
||||
json!({"type": "request", "command": "runInTerminal", "arguments": {"kind": "integrated", "args": args, "cwd": std::env::temp_dir()}})
|
||||
@@ -299,7 +319,8 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
}
|
||||
if script.start_debugging && msg["arguments"]["isChild"] != true {
|
||||
send!(
|
||||
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
|
||||
// Like js-debug, the child's configuration repeats the launch's `type`.
|
||||
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true, "type": msg["arguments"]["type"]}}})
|
||||
);
|
||||
}
|
||||
if script.pending_startup != Some(false) {
|
||||
@@ -378,7 +399,21 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
}
|
||||
tokio::time::sleep(script.launch_delay).await;
|
||||
if let Some(l) = pending_launch.take() {
|
||||
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
|
||||
let answer = json!({"type": "response", "request_seq": l, "command": "launch", "success": true});
|
||||
if child && !script.child_launch_delay.is_zero() {
|
||||
starting.store(true, Ordering::Release);
|
||||
let (w, starting, delay) = (w.clone(), starting.clone(), script.child_launch_delay);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
let mut answer = answer;
|
||||
answer["seq"] = 999_999.into();
|
||||
let _ = framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&answer).unwrap())
|
||||
.await;
|
||||
starting.store(false, Ordering::Release);
|
||||
});
|
||||
} else {
|
||||
send!(answer);
|
||||
}
|
||||
}
|
||||
if let Some(pid) = script.debuggee_pid {
|
||||
let event = if script.java_process_id {
|
||||
@@ -474,9 +509,25 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
}
|
||||
"disconnect" => {
|
||||
ok!(msg, Value::Null);
|
||||
let _ = w.shutdown().await;
|
||||
let _ = w.lock().await.shutdown().await;
|
||||
break;
|
||||
}
|
||||
other if script.accept.contains(&other) => {
|
||||
if starting.load(Ordering::Acquire) {
|
||||
log.lock().push(json!({"type": "dropped", "command": other}));
|
||||
}
|
||||
ok!(msg, json!({}))
|
||||
}
|
||||
other if script.custom.and_then(|custom| custom(&msg)).is_some() => {
|
||||
if starting.load(Ordering::Acquire) {
|
||||
log.lock().push(json!({"type": "dropped", "command": other}));
|
||||
}
|
||||
let (body, events) = script.custom.and_then(|custom| custom(&msg)).unwrap();
|
||||
ok!(msg, body);
|
||||
for (event, body) in events {
|
||||
send!(json!({"type": "event", "event": event, "body": body}));
|
||||
}
|
||||
}
|
||||
other => send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"})
|
||||
),
|
||||
@@ -905,6 +956,162 @@ async fn blocked_program_input_does_not_hold_up_shutdown() {
|
||||
assert!(h.manager.send_input(session, "later").is_err());
|
||||
}
|
||||
|
||||
fn js_config(launch_type: &str) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
adapter: "js-debug".into(),
|
||||
program: None,
|
||||
args: Vec::new(),
|
||||
extra_json: Some(format!(r#"{{"type": "{launch_type}"}}"#)),
|
||||
..config()
|
||||
}
|
||||
}
|
||||
|
||||
/// The session the adapter started under `parent`.
|
||||
async fn child_of(h: &Harness, parent: DebugSessionId) -> DebugSessionId {
|
||||
for _ in 0..200 {
|
||||
if let Some(child) = h.manager.sessions(Some("main")).into_iter().find(|s| s.parent == Some(parent)) {
|
||||
return child.id;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("no child session of {parent:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_browser_page_session_has_the_page_capability() {
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = h.manager.capabilities(child_of(&h, browser).await).unwrap();
|
||||
assert!(page.js_page && page.live_evaluate);
|
||||
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
|
||||
|
||||
let node = h.start(js_config("pwa-node")).await.unwrap();
|
||||
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
|
||||
assert!(!process.js_page && process.live_evaluate);
|
||||
|
||||
let python = h.start(config()).await.unwrap();
|
||||
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
|
||||
assert!(!script.js_page && !script.live_evaluate);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_breakpoints_are_sent_to_a_page_and_refused_elsewhere() {
|
||||
let h = harness(Script { start_debugging: true, accept: &["setCustomBreakpoints"], ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], vec!["api/".into()]).await.unwrap();
|
||||
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": ["api/"]})]);
|
||||
|
||||
let not_a_page = h.manager.js_set_custom_breakpoints(browser, Vec::new(), Vec::new()).await.unwrap_err();
|
||||
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
|
||||
// A js-debug build without the request.
|
||||
let old = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = old.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&old, browser).await;
|
||||
let refused = old.manager.js_set_custom_breakpoints(page, Vec::new(), Vec::new()).await.unwrap_err();
|
||||
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
}
|
||||
|
||||
/// js-debug answers a request of its own that reaches a page after `configurationDone` and
|
||||
/// before the page's `launch` is answered, and then does nothing with it.
|
||||
#[tokio::test]
|
||||
async fn a_page_gets_its_requests_once_its_session_has_started() {
|
||||
let h = harness(Script {
|
||||
start_debugging: true,
|
||||
accept: &["setCustomBreakpoints"],
|
||||
child_launch_delay: Duration::from_millis(150),
|
||||
..Script::default()
|
||||
});
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
// Past the page's `configurationDone`, with its `launch` still unanswered.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], Vec::new()).await.unwrap();
|
||||
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": []})]);
|
||||
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
|
||||
assert_eq!(dropped, 0, "the request reached the page before it took requests");
|
||||
}
|
||||
|
||||
/// js-debug's network requests: `enableNetworking` is followed by one mirrored browser event.
|
||||
fn network_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
match msg["command"].as_str()? {
|
||||
"enableNetworking" => Some((
|
||||
json!({}),
|
||||
vec![(
|
||||
"networkEvent",
|
||||
json!({ "event": "requestWillBeSent", "data": {
|
||||
"requestId": "7", "timestamp": 1.5, "type": "Fetch",
|
||||
"request": { "method": "GET", "url": "http://localhost:5173/api/items", "headers": {} }
|
||||
} }),
|
||||
)],
|
||||
)),
|
||||
"networkCall" => Some((json!({ "body": "é".repeat(600_000), "base64Encoded": false }), Vec::new())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_page_reports_its_requests_and_their_bodies() {
|
||||
let h = harness(Script {
|
||||
start_debugging: true,
|
||||
custom: Some(network_adapter),
|
||||
child_launch_delay: Duration::from_millis(100),
|
||||
..Script::default()
|
||||
});
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
|
||||
// Recording is asked for by the session itself, as soon as js-debug takes the request:
|
||||
// once it has answered the page's `launch` (before that, the request is never answered).
|
||||
let DebugEvent::JsNetwork { event } = h.wait_event(|e| matches!(e, DebugEvent::JsNetwork { .. })).await else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
requests(&h, "enableNetworking"),
|
||||
[json!({ "mirrorEvents": ["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"] })]
|
||||
);
|
||||
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
|
||||
assert_eq!(dropped, 0, "recording was asked for before the page took requests");
|
||||
assert_eq!((event.request_id.as_str(), event.timestamp_ms), ("7", 1500));
|
||||
assert_eq!(event.url.as_deref(), Some("http://localhost:5173/api/items"));
|
||||
|
||||
let body = h.manager.js_response_body(page, "7".into()).await.unwrap();
|
||||
assert_eq!(requests(&h, "networkCall"), [json!({ "method": "getResponseBody", "params": { "requestId": "7" } })]);
|
||||
// 1.2 MB of two-byte characters: cut at the limit, on a character.
|
||||
assert!(body.truncated && !body.base64_encoded);
|
||||
assert_eq!(body.body.len(), 1_048_576);
|
||||
assert!(body.body.chars().all(|c| c == 'é'));
|
||||
|
||||
assert_eq!(
|
||||
h.manager.js_response_body(browser, "7".into()).await.unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Unavailable
|
||||
);
|
||||
|
||||
// A Node.js program is not a page: nothing is asked of it.
|
||||
let node = h.start(js_config("pwa-node")).await.unwrap();
|
||||
child_of(&h, node).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert_eq!(requests(&h, "enableNetworking").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
|
||||
// A js-debug build without `enableNetworking`.
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
child_of(&h, browser).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -117,6 +117,14 @@ pub(crate) fn oracle_err(e: &oracledb::Error) -> IdeError {
|
||||
// config first. Treat it as a logon failure, so the UI prompts for one.
|
||||
let kind = match e.kind() {
|
||||
oracledb::ErrorKind::NoCredentials => ErrorKind::PermissionDenied,
|
||||
// The thin driver speaks the protocol of Oracle Database 12c and later only.
|
||||
oracledb::ErrorKind::ServerVersionNotSupported => {
|
||||
return IdeError::new(
|
||||
ErrorKind::Unavailable,
|
||||
"This Oracle Database is older than 12c. OpenIDEA connects to Oracle Database 12c and later; \
|
||||
11g and earlier are not supported.",
|
||||
);
|
||||
}
|
||||
_ => ErrorKind::InvalidInput,
|
||||
};
|
||||
return IdeError::new(kind, e.to_string());
|
||||
@@ -571,6 +579,33 @@ mod tests {
|
||||
assert_eq!(connect_string_with(&s("PROD=MYDB"), false).unwrap(), "PROD=MYDB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_older_than_12c_is_named() {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut connect = [0u8; 1024];
|
||||
let _ = socket.read(&mut connect);
|
||||
// A TNS ACCEPT packet with protocol version 314, as Oracle Database 11g answers.
|
||||
let mut accept = vec![0u8; 32];
|
||||
accept[1] = 32;
|
||||
accept[4] = 2;
|
||||
accept[8..10].copy_from_slice(&314u16.to_be_bytes());
|
||||
let _ = socket.write_all(&accept);
|
||||
});
|
||||
let config = oracledb::Config::default()
|
||||
.set_credentials("scott", "tiger")
|
||||
.set_connect_string(&format!("//127.0.0.1:{port}/ORCL"))
|
||||
.unwrap()
|
||||
.set_transport_connect_timeout(Some(std::time::Duration::from_secs(5)));
|
||||
let err = oracle_err(&oracledb::connect(config).err().expect("an 11g server is refused"));
|
||||
assert_eq!(err.kind, ErrorKind::Unavailable, "{err:?}");
|
||||
assert!(err.message.contains("older than 12c"), "{}", err.message);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tns_alias_resolves_when_tnsnames_exists() {
|
||||
let alias = DataSource {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
|
||||
//! the SSH host reaching the database host and port, with no database login.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
|
||||
use ide_ssh::{SshFiles, SshManager};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
struct Env {
|
||||
_tmp: tempfile::TempDir,
|
||||
db: DbManager,
|
||||
ssh_port: u16,
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
|
||||
let ssh = Arc::new(SshManager::with_files(
|
||||
root.join("ssh-secrets.json"),
|
||||
SshFiles { known_hosts: root.join("known_hosts"), config: None },
|
||||
));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
Env { _tmp: tmp, db, ssh_port }
|
||||
}
|
||||
|
||||
impl Env {
|
||||
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
|
||||
fn source(&self, db_port: u16) -> DataSource {
|
||||
DataSource {
|
||||
id: "pg".into(),
|
||||
name: "pg".into(),
|
||||
driver: DbDriver::Postgres,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(db_port),
|
||||
user: "app".into(),
|
||||
ssh: Some(DbSsh {
|
||||
enabled: true,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(self.ssh_port),
|
||||
user: "dev".into(),
|
||||
auth: DbSshAuth::Password,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A port something listens on (and never speaks a database protocol).
|
||||
async fn listening() -> (TcpListener, u16) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(listener, port)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_tunnel_is_tested_without_a_database_login() {
|
||||
let env = env().await;
|
||||
let (_db, db_port) = listening().await;
|
||||
let source = env.source(db_port);
|
||||
|
||||
// First contact: the host key has to be trusted, as with Test Connection.
|
||||
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
|
||||
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
|
||||
env.db.trust_ssh_host("pg").await.unwrap();
|
||||
|
||||
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
|
||||
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
|
||||
|
||||
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
|
||||
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
|
||||
let env = env().await;
|
||||
let (listener, closed) = listening().await;
|
||||
drop(listener);
|
||||
let source = env.source(closed);
|
||||
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
|
||||
env.db.trust_ssh_host("pg").await.unwrap();
|
||||
|
||||
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
|
||||
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_source_without_a_tunnel_has_nothing_to_test() {
|
||||
let env = env().await;
|
||||
let mut source = env.source(5432);
|
||||
source.ssh.as_mut().unwrap().enabled = false;
|
||||
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
|
||||
}
|
||||
@@ -42,6 +42,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
test(&c.host, &c.window, source, password, ssh_secret).await
|
||||
});
|
||||
d.add("db_test_tunnel", |c| async move {
|
||||
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
|
||||
test_tunnel(&c.host, &c.window, source, ssh_secret).await
|
||||
});
|
||||
d.add("db_set_ssh_secret", |c| async move {
|
||||
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
|
||||
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
|
||||
@@ -212,6 +216,17 @@ pub async fn test(
|
||||
db.test(&source, password, ssh_secret).await
|
||||
}
|
||||
|
||||
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
|
||||
pub async fn test_tunnel(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
source: DataSource,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.test_tunnel(&source, ssh_secret).await
|
||||
}
|
||||
|
||||
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
|
||||
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
|
||||
@@ -372,6 +372,25 @@ pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessi
|
||||
host.debugger.restart_frame(session, frame_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_custom_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
ids: Vec<String>,
|
||||
xhr: Vec<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_response_body(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
request_id: String,
|
||||
) -> IdeResult<ide_dap::JsResponseBody> {
|
||||
host.debugger.js_response_body(session, request_id).await
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
@@ -588,6 +607,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
|
||||
d.add("debug_js_response_body", |c| async move {
|
||||
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
|
||||
});
|
||||
d.add("debug_js_custom_breakpoints", |c| async move {
|
||||
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
|
||||
});
|
||||
d.add("debug_set_function_breakpoints", |c| async move {
|
||||
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -31,6 +31,7 @@ pub mod scaffold;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
pub mod spell;
|
||||
mod ssh;
|
||||
pub mod syntax;
|
||||
pub mod term;
|
||||
pub mod tools;
|
||||
@@ -95,6 +96,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
deploy::register(d);
|
||||
docker::register(d);
|
||||
db::register(d);
|
||||
ssh::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! SSH configuration commands shared by the desktop shell's IPC commands and the remote
|
||||
//! server ([`crate::rpc`]). A remote window connects from the server (deployment, database
|
||||
//! tunnels, run targets, SSH sessions), so the host keys it trusts and the passwords it is
|
||||
//! given belong to the server's [`ide_ssh::SshManager`], not the desktop's.
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::remote_settings::SshConfiguration;
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("ssh_test", |c| async move {
|
||||
let (config, secret) = (c.arg::<SshConfiguration>("config")?, c.arg::<Option<String>>("secret")?);
|
||||
c.host.ssh.test(&config, secret).await
|
||||
});
|
||||
d.add("ssh_set_secret", |c| async move {
|
||||
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
|
||||
c.host.ssh.set_secret(&id, secret, save);
|
||||
Ok(())
|
||||
});
|
||||
d.add("ssh_password_saved", |c| async move { Ok(c.host.ssh.password_saved(&c.arg::<String>("key")?)) });
|
||||
d.add("ssh_needs_secret", |c| async move {
|
||||
let ssh = &c.host.ssh;
|
||||
Ok(ssh.needs_secret(&ssh.config(&c.arg::<String>("id")?)?))
|
||||
});
|
||||
d.add("ssh_trust_host", |c| async move { c.host.ssh.trust_host(&c.arg::<String>("id")?).await });
|
||||
d.add("ssh_connected", |c| async move { Ok(c.host.ssh.connected()) });
|
||||
d.add("ssh_disconnect", |c| async move {
|
||||
c.host.ssh.disconnect(&c.arg::<String>("id")?);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -585,6 +585,21 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
/// Adds `commit` to annotate's ignore list in `path`'s repository (the file `blame.ignoreRevsFile`
|
||||
/// names, else `.git-blame-ignore-revs`, which the local config then names).
|
||||
pub async fn vcs_ignore_blame_revision(host: &Host, window: &str, path: PathBuf, commit: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let result = project.with_disk_sync(|| project.with_repo_of(&path, |r| r.ignore_blame_revision(&commit)));
|
||||
if result.is_ok() {
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Project-host operation (also registered with the remote dispatcher).
|
||||
/// Returns the ignore files that were updated, so the UI can show them.
|
||||
pub async fn vcs_add_to_ignore(
|
||||
@@ -792,12 +807,19 @@ pub async fn vcs_stash_push(
|
||||
message: Option<String>,
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let paths = paths.unwrap_or_default();
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index)
|
||||
if paths.is_empty() {
|
||||
return project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index, &[])
|
||||
});
|
||||
}
|
||||
per_repo(&project, &paths, Some("Git stash"), |r, paths| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index, paths)
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1462,6 +1484,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_add_to_ignore", |c| async move {
|
||||
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
|
||||
});
|
||||
d.add("vcs_ignore_blame_revision", |c| async move {
|
||||
vcs_ignore_blame_revision(&c.host, &c.window, c.arg("path")?, c.arg("commit")?).await
|
||||
});
|
||||
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
|
||||
@@ -1507,7 +1532,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 });
|
||||
@@ -1520,6 +1545,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("message")?,
|
||||
c.arg("includeUntracked")?,
|
||||
c.arg("keepIndex")?,
|
||||
c.arg("paths")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
@@ -2,10 +2,106 @@
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::proxy_settings::{ProxyMode, ProxySettings};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Whether `url` names this machine (`localhost` or a loopback address).
|
||||
fn is_loopback(url: &reqwest::Url) -> bool {
|
||||
let host = url.host_str().unwrap_or_default().trim_start_matches('[').trim_end_matches(']');
|
||||
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
|
||||
}
|
||||
|
||||
fn is_html(content_type: &str) -> bool {
|
||||
let kind = content_type.split(';').next().unwrap_or_default().trim();
|
||||
kind.eq_ignore_ascii_case("text/html") || kind.eq_ignore_ascii_case("application/xhtml+xml")
|
||||
}
|
||||
|
||||
/// A client that reaches this machine only: no proxy, and a redirect is followed while it
|
||||
/// stays here (a dev server sending `/` to `/app/`).
|
||||
fn loopback_client() -> IdeResult<reqwest::Client> {
|
||||
let direct = ProxySettings { mode: ProxyMode::None, ..ProxySettings::default() };
|
||||
let stay = reqwest::redirect::Policy::custom(|attempt| {
|
||||
if is_loopback(attempt.url()) && attempt.previous().len() < 5 { attempt.follow() } else { attempt.stop() }
|
||||
});
|
||||
ide_http::client::build_with_redirect(&direct, stay)
|
||||
}
|
||||
|
||||
/// Whether `url`, a server on this machine, answers a browser with an HTML document: a page
|
||||
/// to open, as a dev server has, and not an API endpoint. Other hosts are never asked.
|
||||
pub async fn serves_page(url: &str) -> bool {
|
||||
let Ok(url) = reqwest::Url::parse(url) else { return false };
|
||||
if !matches!(url.scheme(), "http" | "https") || !is_loopback(&url) {
|
||||
return false;
|
||||
}
|
||||
let Ok(client) = loopback_client() else { return false };
|
||||
// A dev server may compile the page on this first request.
|
||||
let request = client.get(url).header(reqwest::header::ACCEPT, "text/html").timeout(Duration::from_secs(10));
|
||||
let Ok(response) = request.send().await else { return false };
|
||||
let html = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(is_html);
|
||||
response.status().is_success() && html
|
||||
}
|
||||
|
||||
/// The URL serving `file`, under the window's project when it holds the file.
|
||||
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
host.web.url_for(root.as_deref(), file).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A server on a free port that answers every request with `status` and `header`. It
|
||||
/// listens on every interface, so that a test can reach it under a name that is not loopback.
|
||||
async fn answering(status: &'static str, header: String) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut socket, _)) = listener.accept().await {
|
||||
let mut request = [0u8; 2048];
|
||||
let _ = socket.read(&mut request).await;
|
||||
let response =
|
||||
format!("HTTP/1.1 {status}\r\n{header}\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
format!("http://127.0.0.1:{port}/")
|
||||
}
|
||||
|
||||
async fn server(status: &'static str, content_type: &'static str) -> String {
|
||||
answering(status, format!("Content-Type: {content_type}")).await
|
||||
}
|
||||
|
||||
async fn redirecting_to(location: &str) -> String {
|
||||
answering("302 Found", format!("Location: {location}")).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_redirect_is_followed_on_this_machine_only() {
|
||||
let page = server("200 OK", "text/html").await;
|
||||
assert!(serves_page(&redirecting_to(&page).await).await);
|
||||
// The same page under an address that is not loopback: it is not asked.
|
||||
let elsewhere = page.replace("127.0.0.1", "0.0.0.0");
|
||||
assert!(!serves_page(&redirecting_to(&elsewhere).await).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_page_is_an_html_answer_from_this_machine() {
|
||||
assert!(serves_page(&server("200 OK", "text/html; charset=utf-8").await).await);
|
||||
assert!(serves_page(&server("200 OK", "Text/HTML").await.replace("127.0.0.1", "localhost")).await);
|
||||
// An API, an error page and a port nothing listens on are not pages.
|
||||
assert!(!serves_page(&server("200 OK", "application/json").await).await);
|
||||
assert!(!serves_page(&server("404 Not Found", "text/html").await).await);
|
||||
assert!(!serves_page("http://127.0.0.1:1/").await);
|
||||
// Other machines are not asked at all.
|
||||
assert!(!serves_page("http://example.com/").await);
|
||||
assert!(!serves_page("ftp://localhost/").await);
|
||||
assert!(!serves_page("not a url").await);
|
||||
}
|
||||
}
|
||||
@@ -1642,6 +1642,9 @@ impl HostApi for PluginApi {
|
||||
) -> Result<(), HostError> {
|
||||
self.set_decorations(path, set, Vec::new(), item, true)
|
||||
}
|
||||
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
self.set_file_icon_decorations(set, items)
|
||||
}
|
||||
fn vcs_log(
|
||||
&self,
|
||||
root: &Path,
|
||||
@@ -4124,6 +4127,9 @@ impl PluginApi {
|
||||
{
|
||||
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
|
||||
}
|
||||
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
|
||||
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
|
||||
}
|
||||
items.retain(|d| decoration_style(&d.style));
|
||||
if items.iter().any(|d| d.range.start > d.range.end) {
|
||||
return Err(HostError::Invalid("reversed decoration range".into()));
|
||||
@@ -4132,24 +4138,14 @@ impl PluginApi {
|
||||
// The decoration follows the file, not the window's project: editors render
|
||||
// whatever they have open, including files outside every project root. The
|
||||
// set's root stays the path's own (deepest open) project for tree filtering.
|
||||
let root = self
|
||||
.shared
|
||||
.workspace()
|
||||
.map(|w| {
|
||||
w.projects()
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
|
||||
let root = self.decoration_root(path);
|
||||
Some(super::DecorationSet {
|
||||
root: root.into(),
|
||||
path: ide_core::IpcPath(path.to_path_buf()),
|
||||
set_id: set.into(),
|
||||
tree,
|
||||
items,
|
||||
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
|
||||
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -4177,6 +4173,60 @@ impl PluginApi {
|
||||
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decoration_root(&self, path: &Path) -> PathBuf {
|
||||
self.shared
|
||||
.workspace()
|
||||
.map(|w| {
|
||||
w.projects()
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
if set.is_empty() || set.len() > 128 {
|
||||
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
|
||||
}
|
||||
if items.len() > 10_000 {
|
||||
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
|
||||
}
|
||||
let mut by_path = BTreeMap::new();
|
||||
for (path, icon) in items {
|
||||
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
|
||||
by_path.insert(path, icon);
|
||||
}
|
||||
let mut all = self.shared.decorations.lock();
|
||||
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
|
||||
sets.retain(|s| !(s.tree && s.set_id == set));
|
||||
for (path, icon) in by_path {
|
||||
let root = self.decoration_root(&path);
|
||||
sets.push(super::DecorationSet {
|
||||
root: root.into(),
|
||||
path: ide_core::IpcPath(path),
|
||||
set_id: set.into(),
|
||||
tree: true,
|
||||
items: Vec::new(),
|
||||
file: Some(ide_plugin::FileDecoration {
|
||||
badge: None,
|
||||
style: String::new(),
|
||||
tooltip: None,
|
||||
note: None,
|
||||
icon: Some(icon),
|
||||
}),
|
||||
});
|
||||
}
|
||||
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
|
||||
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
|
||||
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
|
||||
}
|
||||
all.insert(self.id().into(), sets.clone());
|
||||
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn decoration_style(style: &str) -> bool {
|
||||
matches!(
|
||||
@@ -4330,7 +4380,8 @@ mod workbench_tests {
|
||||
badge: Some("abc".into()),
|
||||
style: "info".into(),
|
||||
tooltip: None,
|
||||
note: None
|
||||
note: None,
|
||||
icon: None,
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
@@ -4340,6 +4391,7 @@ mod workbench_tests {
|
||||
style: String::new(),
|
||||
tooltip: Some("why".into()),
|
||||
note: Some(text.into()),
|
||||
icon: None,
|
||||
};
|
||||
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
|
||||
let notes = |api: &PluginApi| {
|
||||
@@ -4353,6 +4405,18 @@ mod workbench_tests {
|
||||
for bad in ["", "two\nlines", &"x".repeat(201)] {
|
||||
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
|
||||
}
|
||||
api.file_icon_decorations(
|
||||
"icons",
|
||||
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
|
||||
)
|
||||
.unwrap();
|
||||
let icons = api.shared.decoration_sets()["test"]
|
||||
.iter()
|
||||
.filter(|s| s.tree && s.set_id == "icons")
|
||||
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(icons.len(), 2);
|
||||
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
|
||||
api.shared.clear_plugin("test", true);
|
||||
assert!(api.shared.decoration_sets().is_empty());
|
||||
}
|
||||
|
||||
@@ -396,15 +396,37 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
|
||||
self.provider_plugins(kind, &doc.language)
|
||||
.into_iter()
|
||||
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
|
||||
}
|
||||
|
||||
pub fn provide_completion(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> Vec<(String, CompletionItem)> {
|
||||
self.feature_documents(doc, Some(at))
|
||||
self.provide_completion_for_editor(doc, at, trigger).1
|
||||
}
|
||||
|
||||
pub(crate) fn provide_completion_for_editor(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> (bool, Vec<(String, CompletionItem)>) {
|
||||
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
|
||||
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
|
||||
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
|
||||
let items = self
|
||||
.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.flat_map(|(doc, bounds)| {
|
||||
if self.has_document_providers(ProviderKind::Completion, &doc) {
|
||||
covered = true;
|
||||
}
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
@@ -419,7 +441,8 @@ impl PluginHost {
|
||||
Some((p, i))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(covered, items)
|
||||
}
|
||||
|
||||
pub fn provide_code_actions(
|
||||
|
||||
@@ -743,6 +743,7 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct FileIconDecl {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
patterns: Vec<String>,
|
||||
icon: PathBuf,
|
||||
#[serde(default)]
|
||||
@@ -754,13 +755,19 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
|
||||
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
|
||||
}
|
||||
|
||||
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
|
||||
/// whatever icon theme is in use (the user's own file type icons still win).
|
||||
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
|
||||
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
|
||||
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
|
||||
/// icons still win).
|
||||
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
|
||||
validate_item_id(&d.id, "file icon id")?;
|
||||
let what = format!("file icon {:?}", d.id);
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
if d.patterns.is_empty() {
|
||||
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
|
||||
} else {
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
|
||||
@@ -769,8 +776,16 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
|
||||
assets.push((file_icon_asset(&d.id, dark), svg));
|
||||
files.push(path.clone());
|
||||
}
|
||||
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
|
||||
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
|
||||
let revision = blake3::hash(&bytes).to_hex().to_string();
|
||||
let json = serde_json::json!({
|
||||
"id": d.id,
|
||||
"patterns": d.patterns,
|
||||
"dark": d.icon_dark.is_some(),
|
||||
"revision": revision,
|
||||
});
|
||||
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
|
||||
let mut out = UiDecl::new(d.id.clone(), label, json);
|
||||
out.files = files;
|
||||
out.assets = assets;
|
||||
Ok(out)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -99,6 +99,21 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
|
||||
let path = root.join("a.txt");
|
||||
std::fs::write(&path, "value\nnext\nend").unwrap();
|
||||
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
|
||||
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
|
||||
let toml_path = root.join("a.toml");
|
||||
std::fs::write(&toml_path, "k = 1\n").unwrap();
|
||||
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ide_core::error::ErrorKind::Unavailable
|
||||
);
|
||||
let sql_path = root.join("q.sql");
|
||||
std::fs::write(&sql_path, "SELECT 1;").unwrap();
|
||||
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
|
||||
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
|
||||
.parameters,
|
||||
|
||||
@@ -2554,7 +2554,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
f.install(&official("git-toolbox"));
|
||||
// Focusing an editor wakes it to show the branch (nothing fetched yet).
|
||||
let now = Instant::now();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
|
||||
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
|
||||
f.host.flush_events_at(now + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let item = |host: &PluginHost| {
|
||||
@@ -2992,13 +2993,18 @@ fn official_csv_editor_edits_the_shared_document() {
|
||||
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
|
||||
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
|
||||
|
||||
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
|
||||
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
|
||||
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
|
||||
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
|
||||
|
||||
// Settings: without a header row every record is a row.
|
||||
let mut values = serde_json::Map::new();
|
||||
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
|
||||
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
|
||||
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
|
||||
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
|
||||
|
||||
// Undeclared views are refused before any code runs.
|
||||
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
|
||||
@@ -3754,6 +3760,43 @@ path = "templates/new.draw"
|
||||
assert!(f.host.web_page("web-demo", "draw").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_icons_without_patterns_require_api_1_8_and_project_assets() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("dynamic-icons");
|
||||
std::fs::create_dir_all(pkg.join("icons")).unwrap();
|
||||
std::fs::write(pkg.join("icons/helm-dir.svg"), r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>"#)
|
||||
.unwrap();
|
||||
let write_manifest = |api: &str| {
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"id = "dynamic-icons"
|
||||
name = "Dynamic Icons"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "{api}"
|
||||
|
||||
[[contributes.fileIcons]]
|
||||
id = "helm-dir"
|
||||
icon = "icons/helm-dir.svg"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
write_manifest("^1.7");
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("1.8"), "{err:?}");
|
||||
|
||||
write_manifest("^1.8");
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let assets = f.host.assets("dynamic-icons", "fileIcon/helm-dir/").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("<circle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_templates_fixture_installs_without_write_capabilities() {
|
||||
let f = fixture(Limits::default());
|
||||
|
||||
@@ -367,6 +367,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
|
||||
use ide_core::{Diagnostic, Pos, Range, Severity};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let desktop = Arc::new(
|
||||
@@ -426,42 +427,70 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
"severity": "warning", "message": message, "source": "remote-check", "code": null
|
||||
}]})
|
||||
};
|
||||
let publish = |path: &Path, source: &str, message: Option<&str>| {
|
||||
let items = message
|
||||
.map(|message| Diagnostic {
|
||||
range: Range::new(Pos::new(0, 1), Pos::new(0, 3)),
|
||||
severity: Severity::Warning,
|
||||
message: message.into(),
|
||||
source: Some(source.into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
host.project("main").unwrap().sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.to_path_buf().into(),
|
||||
source: source.into(),
|
||||
items,
|
||||
});
|
||||
};
|
||||
let files = json!([
|
||||
problem(&remote_file, "remote warning"),
|
||||
problem(&other_file, "other warning"),
|
||||
problem(&outside_file, "outside warning")
|
||||
]);
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
// UI reports are not the source of the plugin's host-owned snapshot.
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
publish(&other_file, "remote-check", Some("other warning"));
|
||||
publish(&outside_file, "remote-check", Some("outside warning"));
|
||||
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(problems.contains("other warning"), "{problems}");
|
||||
assert!(!problems.contains("outside warning"), "{problems}");
|
||||
assert!(problems.contains("outside warning"), "{problems}");
|
||||
for path in ["remote.txt", remote_file.to_str().unwrap()] {
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(!problems.contains("other warning"), "{problems}");
|
||||
assert!(!problems.contains("outside warning"), "{problems}");
|
||||
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
||||
}
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
|
||||
.await
|
||||
.unwrap();
|
||||
publish(&remote_file, "second-check", Some("second warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(problems.as_str().unwrap().contains("remote warning"));
|
||||
assert!(problems.as_str().unwrap().contains("second warning"));
|
||||
publish(&remote_file, "remote-check", None);
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(!problems.as_str().unwrap().contains("remote warning"));
|
||||
assert!(problems.as_str().unwrap().contains("second warning"));
|
||||
assert!(problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(
|
||||
&peer,
|
||||
"mcp_report_problems",
|
||||
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
publish(&remote_file, "second-check", Some("replacement warning"));
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(problems.as_str().unwrap().contains("replacement warning"));
|
||||
assert!(!problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
assert!(!problems.as_str().unwrap().contains("second warning"));
|
||||
assert!(problems.as_str().unwrap().contains("other warning"));
|
||||
publish(&remote_file, "second-check", None);
|
||||
publish(&other_file, "remote-check", None);
|
||||
publish(&outside_file, "remote-check", None);
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
|
||||
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
|
||||
@@ -513,11 +542,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
stream.read_to_string(&mut response).await.unwrap();
|
||||
assert!(response.contains("You can close this tab"));
|
||||
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
|
||||
let plugin_host = plugins.host().clone();
|
||||
@@ -537,7 +566,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.as_deref(), Some("[]"));
|
||||
// Disconnect clears desktop reports, not the still-live host project snapshot.
|
||||
let problems = result.as_deref().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(problems.contains("remote-check"), "{problems}");
|
||||
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
||||
plugins.host().shutdown();
|
||||
desktop.shutdown();
|
||||
}
|
||||
|
||||
@@ -341,6 +341,7 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
"db_save_sources",
|
||||
"db_set_password",
|
||||
"db_test",
|
||||
"db_test_tunnel",
|
||||
"db_set_ssh_secret",
|
||||
"db_trust_ssh_host",
|
||||
"db_connected",
|
||||
@@ -364,6 +365,13 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
"db_table_page",
|
||||
"db_apply_edits",
|
||||
"db_console_path",
|
||||
"ssh_test",
|
||||
"ssh_set_secret",
|
||||
"ssh_password_saved",
|
||||
"ssh_needs_secret",
|
||||
"ssh_trust_host",
|
||||
"ssh_connected",
|
||||
"ssh_disconnect",
|
||||
] {
|
||||
assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}");
|
||||
}
|
||||
@@ -372,6 +380,12 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
// A remote window's host-key and password remedies reach the server's SSH manager,
|
||||
// the one its deployment / database connections failed in.
|
||||
let trust = invoke(&peer, "ssh_trust_host", json!({"id": "nothing-pending"})).await.unwrap_err();
|
||||
assert!(trust.message.contains("no host key to trust"), "{trust:?}");
|
||||
invoke(&peer, "ssh_set_secret", json!({"id": "deploy", "secret": "pw", "save": false})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "ssh_connected", json!({})).await.unwrap(), json!([]));
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"});
|
||||
@@ -481,3 +495,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())),
|
||||
|
||||
@@ -102,7 +102,7 @@ async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
let edited = format!("{ORIGINAL}\n");
|
||||
f.saved_edit(&edited);
|
||||
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
|
||||
vcs::vcs_stash_push(&f.host, "main", None, None, false, false, None).await.unwrap();
|
||||
f.assert_clean(ORIGINAL);
|
||||
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
|
||||
f.assert_clean(&edited);
|
||||
|
||||
@@ -168,7 +168,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
|
||||
f.save(main_rs).await;
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false, None).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(!f.text(main_rs).await.contains("work in progress"));
|
||||
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ license.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -52,26 +52,35 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn parse_env(text: &str) -> IdeResult<EnvMap> {
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
let invalid = |e| IdeError::invalid(format!("invalid environment file: {e}"));
|
||||
// Editors on Windows (Notepad) save UTF-8 with a byte order mark, which serde_json rejects.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
|
||||
let value: Box<RawValue> = serde_json::from_str(text).map_err(invalid)?;
|
||||
let mut out = EnvMap::new();
|
||||
let Some(envs) = value.as_object() else { return Ok(out) };
|
||||
if !value.get().starts_with('{') {
|
||||
return Ok(out);
|
||||
}
|
||||
let envs: BTreeMap<String, Box<RawValue>> = serde_json::from_str(value.get()).map_err(invalid)?;
|
||||
for (env, vars) in envs {
|
||||
let Some(vars) = vars.as_object() else { continue };
|
||||
if !vars.get().starts_with('{') {
|
||||
continue;
|
||||
}
|
||||
let vars: BTreeMap<String, Box<RawValue>> = serde_json::from_str(vars.get()).map_err(invalid)?;
|
||||
let mut map = BTreeMap::new();
|
||||
for (k, v) in vars {
|
||||
let s = match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
let raw = v.get();
|
||||
let s = match raw.as_bytes().first() {
|
||||
Some(b'"') => serde_json::from_str::<String>(raw).map_err(invalid)?,
|
||||
// Keep numeric tokens: Value::Number would round decimals and large integers.
|
||||
Some(b'-' | b'0'..=b'9' | b't' | b'f') => raw.to_owned(),
|
||||
// Objects configure SSL / auth in IDEA; not variables.
|
||||
_ => continue,
|
||||
};
|
||||
map.insert(k.clone(), s);
|
||||
map.insert(k, s);
|
||||
}
|
||||
out.insert(env.clone(), map);
|
||||
out.insert(env, map);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -304,6 +313,85 @@ impl Resolver<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preserves_exact_numeric_environment_tokens() {
|
||||
for token in [
|
||||
"0",
|
||||
"-0",
|
||||
"0.0",
|
||||
"-0.000",
|
||||
"1",
|
||||
"-1",
|
||||
"9007199254740991",
|
||||
"9007199254740992",
|
||||
"9007199254740993",
|
||||
"9223372036854775807",
|
||||
"-9223372036854775808",
|
||||
"18446744073709551615",
|
||||
"18446744073709551616",
|
||||
"123456789012345678901234567890",
|
||||
"-123456789012345678901234567890",
|
||||
"1.0000000000000000002",
|
||||
"-1.0000000000000000002",
|
||||
"0.12345678901234567890123456789",
|
||||
"-0.0000000000000000000000000001",
|
||||
"1e-400",
|
||||
"-1e-400",
|
||||
"1e400",
|
||||
"-1e400",
|
||||
"1.2300e+20",
|
||||
"1.2300E+020",
|
||||
] {
|
||||
let json = format!(r#"{{"dev":{{"value":{token}}}}}"#);
|
||||
let envs = Environments::from_json(&json, "").unwrap();
|
||||
let vars = envs.variables(Some("dev"));
|
||||
assert_eq!(vars["value"], token);
|
||||
let resolver = Resolver { file_vars: &[], env: vars };
|
||||
let mut missing = BTreeSet::new();
|
||||
assert_eq!(resolver.substitute("{\"value\":{{value}}}", &mut missing), format!(r#"{{"value":{token}}}"#));
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_numeric_environment_tokens() {
|
||||
for token in ["01", "+1", "1.", ".1", "NaN", "Infinity", "--1", "1e", "1e+", "1e--2", "0xFF"] {
|
||||
let json = format!(r#"{{"dev":{{"value":{token}}}}}"#);
|
||||
assert!(Environments::from_json(&json, "").is_err(), "accepted {token}");
|
||||
}
|
||||
assert!(Environments::from_json(r#"{"dev":{"ssl":{"invalid":01}}}"#, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_numeric_tokens_with_whitespace_and_escaped_names() {
|
||||
let envs = Environments::from_json(
|
||||
"\u{feff} \n { \"de\\u0076\": \n {\"va\\u006cue\": \t 1.2300E+020, \"value\": 1.0000000000000000002 }} \n",
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
assert_eq!(envs.variables(Some("dev"))["value"], "1.0000000000000000002");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_environment_parsing_keeps_types_and_precedence() {
|
||||
let envs = Environments::from_json(
|
||||
r#"{"$shared":{"shared":1.0000000000000000002},"dev":{"v":1,"v":9007199254740993,"enabled":true,"disabled":false,"s":"a\n\"b","null":null,"ssl":{},"arr":[]},"other":42}"#,
|
||||
r#"{"dev":{"v":9223372036854775807}}"#,
|
||||
).unwrap();
|
||||
let vars = envs.variables(Some("dev"));
|
||||
assert_eq!(vars["v"], "9223372036854775807");
|
||||
assert_eq!(vars["shared"], "1.0000000000000000002");
|
||||
assert_eq!(vars["enabled"], "true");
|
||||
assert_eq!(vars["disabled"], "false");
|
||||
assert_eq!(vars["s"], "a\n\"b");
|
||||
assert!(!vars.contains_key("null"));
|
||||
assert!(!vars.contains_key("ssl"));
|
||||
assert!(!vars.contains_key("arr"));
|
||||
assert_eq!(Environments::from_json("[]", "").unwrap().names(), Vec::<String>::new());
|
||||
assert!(Environments::from_json(r#"{"dev":{"v":01}}"#, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_variable_keys_in_every_environment_private_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -249,6 +249,68 @@ mod tests {
|
||||
assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_exact_environment_numbers_to_a_local_server() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let (headers, body) = loop {
|
||||
let mut buffer = [0u8; 4096];
|
||||
let read = socket.read(&mut buffer).await.unwrap();
|
||||
assert!(read > 0, "request ended before its body");
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { continue };
|
||||
let headers = String::from_utf8_lossy(&request[..end]);
|
||||
let length: usize = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap();
|
||||
if request.len() >= end + 4 + length {
|
||||
break (
|
||||
String::from_utf8(request[..end].to_vec()).unwrap(),
|
||||
String::from_utf8(request[end + 4..end + 4 + length].to_vec()).unwrap(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
(headers, body)
|
||||
});
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(crate::env::PUBLIC_FILE),
|
||||
r#"{"dev":{"id":123456789012345678901234567890,"amount":1.0000000000000000002,"tiny":1e-400}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let file = tmp.path().join("precision.http");
|
||||
let text = format!(
|
||||
"POST http://{addr}/echo?id={{{{id}}}}\nContent-Type: application/json\nX-Exact-Amount: {{{{amount}}}}\n\n{{\"id\":{{{{id}}}},\"amount\":{{{{amount}}}},\"tiny\":{{{{tiny}}}}}}\n"
|
||||
);
|
||||
let input =
|
||||
crate::RunInput { text: &text, index: 0, file: Some(&file), root: Some(tmp.path()), env: Some("dev") };
|
||||
let exchange =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), crate::run(input, &CancellationToken::new()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let expected = r#"{"id":123456789012345678901234567890,"amount":1.0000000000000000002,"tiny":1e-400}"#;
|
||||
let (headers, body) = server.await.unwrap();
|
||||
assert_eq!(headers.lines().next().unwrap(), "POST /echo?id=123456789012345678901234567890 HTTP/1.1");
|
||||
assert!(headers.lines().any(|line| line.eq_ignore_ascii_case("x-exact-amount: 1.0000000000000000002")));
|
||||
assert_eq!(body, expected);
|
||||
assert_eq!(exchange.request.body.as_deref(), Some(expected));
|
||||
assert_eq!(exchange.response.unwrap().body.as_deref(), Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_against_a_local_server() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@@ -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);
|
||||
@@ -24,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() {
|
||||
@@ -553,6 +566,67 @@ impl IndexData {
|
||||
self.by_path.get(path).copied()
|
||||
}
|
||||
|
||||
/// Reserves the flat tables for a bulk warm-start hydration.
|
||||
pub(crate) fn reserve(&mut self, files: usize, symbols: usize, dirs: usize) {
|
||||
self.files.reserve(files);
|
||||
self.by_path.reserve(files);
|
||||
self.dirs.reserve(dirs);
|
||||
self.hot.rows.reserve(files);
|
||||
self.dirs_hot.rows.reserve(dirs);
|
||||
self.syms.head.reserve(symbols);
|
||||
self.syms.container.reserve(symbols);
|
||||
self.syms.kind.reserve(symbols);
|
||||
self.syms.range.reserve(symbols);
|
||||
}
|
||||
|
||||
/// Hydrates a validated warm-start snapshot in one pass, without per-file
|
||||
/// duplicate checks, ancestor walks or compaction checks.
|
||||
pub(crate) fn load_bulk(
|
||||
&mut self,
|
||||
entries: Vec<(FileEntry, Vec<StoredSymbol>)>,
|
||||
dirs: Vec<(Box<str>, PathBuf, u32)>,
|
||||
) {
|
||||
self.files = Vec::with_capacity(entries.len());
|
||||
self.by_path = HashMap::with_capacity(entries.len());
|
||||
self.hot = HotFiles::default();
|
||||
self.dirs_hot = HotDirs::default();
|
||||
self.syms = SymTable::default();
|
||||
self.dirs = HashMap::with_capacity(dirs.len());
|
||||
let symbols: usize = entries.iter().map(|(_, symbols)| symbols.len()).sum();
|
||||
self.reserve(entries.len(), symbols, dirs.len());
|
||||
self.symbol_count = 0;
|
||||
for (slot, (mut entry, symbols)) in entries.into_iter().enumerate() {
|
||||
let (sym_start, sym_mask) = self.syms.push(&symbols);
|
||||
entry.sym_start = sym_start;
|
||||
entry.sym_len = symbols.len() as u32;
|
||||
self.symbol_count += entry.sym_len;
|
||||
let rel = self.hot.append(&entry.rel);
|
||||
self.hot.set(
|
||||
slot as u32,
|
||||
FileRow {
|
||||
rel,
|
||||
name_start: entry.name_start,
|
||||
sym_start,
|
||||
sym_len: entry.sym_len,
|
||||
sym_mask,
|
||||
rel_mask: entry.rel_mask,
|
||||
name_mask: entry.name_mask,
|
||||
dir_mask: char_mask(entry.dir()),
|
||||
ascii: entry.rel.is_ascii(),
|
||||
live: true,
|
||||
},
|
||||
);
|
||||
self.by_path.insert(entry.path.clone(), slot as u32);
|
||||
self.files.push(Some(entry));
|
||||
}
|
||||
self.file_count = self.files.len() as u32;
|
||||
for (rel, path, count) in dirs {
|
||||
let row = self.dirs_hot.push(&rel, &path);
|
||||
self.dirs.insert(rel, (path, count, row));
|
||||
}
|
||||
self.free.clear();
|
||||
}
|
||||
|
||||
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
|
||||
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
|
||||
FileRecord {
|
||||
@@ -793,6 +867,23 @@ mod tests {
|
||||
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_load_matches_incremental_insert() {
|
||||
let mut incremental = IndexData::default();
|
||||
insert(&mut incremental, "a/b.rs", 2);
|
||||
insert(&mut incremental, "c.rs", 1);
|
||||
assert_consistent(&incremental);
|
||||
|
||||
let mut bulk = IndexData::default();
|
||||
bulk.load_bulk(vec![entry("a/b.rs", 2), entry("c.rs", 1)], vec![("a/".into(), PathBuf::from("/r/a"), 1)]);
|
||||
assert_consistent(&bulk);
|
||||
assert_eq!((bulk.file_count, bulk.symbol_count), (2, 3));
|
||||
assert_eq!(bulk.hot.text(), incremental.hot.text());
|
||||
assert_eq!(bulk.files.len(), incremental.files.len());
|
||||
assert_eq!(bulk.slot(Path::new("/r/a/b.rs")), incremental.slot(Path::new("/r/a/b.rs")));
|
||||
assert_eq!(bulk.dirs["a/"].1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirs_follow_inserts_and_removals() {
|
||||
let mut d = IndexData::default();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,75 +1,429 @@
|
||||
//! 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) })
|
||||
}
|
||||
|
||||
/// The config and exclude file of the Git directory `git`; a linked worktree's directory
|
||||
/// (`.git/worktrees/<name>`) reads the shared ones its `commondir` names as well.
|
||||
fn insert_git_inputs(out: &mut std::collections::BTreeSet<std::path::PathBuf>, git: &Path) {
|
||||
out.insert(git.join("config"));
|
||||
out.insert(git.join("info/exclude"));
|
||||
if let Ok(common) = std::fs::read_to_string(git.join("commondir")) {
|
||||
let common = Path::new(common.trim());
|
||||
let common = if common.is_absolute() { common.to_owned() } else { git.join(common) };
|
||||
out.insert(common.join("config"));
|
||||
out.insert(common.join("info/exclude"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
insert_git_inputs(&mut out, &git);
|
||||
}
|
||||
out.insert(root.join(".git/config"));
|
||||
out.insert(root.join(".git/info/exclude"));
|
||||
// The walker also reads the ignore files above the root (`parents`), and a project nested
|
||||
// in a repository uses that repository's exclude file and config.
|
||||
for dir in root.ancestors().skip(1) {
|
||||
out.insert(dir.join(".gitignore"));
|
||||
out.insert(dir.join(".ignore"));
|
||||
if let Some(git) = git_dir(dir) {
|
||||
insert_git_inputs(&mut out, &git);
|
||||
}
|
||||
}
|
||||
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 +436,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 +469,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 +594,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 +679,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,95 @@ 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_changed_ignore_rules_above_the_project() {
|
||||
// The project is a folder of a repository whose ignore files sit above it.
|
||||
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
|
||||
let repo = f.root.parent().unwrap().to_owned();
|
||||
std::fs::create_dir_all(repo.join(".git/info")).unwrap();
|
||||
std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 2);
|
||||
drop(idx);
|
||||
|
||||
std::fs::write(repo.join(".gitignore"), "proj/generated/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "a new ignore file above the project invalidates the warm snapshot");
|
||||
drop(idx);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
std::fs::write(repo.join(".gitignore"), "").unwrap();
|
||||
std::fs::write(repo.join(".git/info/exclude"), "proj/src/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "so do the enclosing repository's changed rules");
|
||||
assert!(!symbols(&idx, "a", SymbolFilter::All).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_detects_a_changed_exclude_file_of_a_linked_worktree() {
|
||||
// The project is a linked worktree: its `.git` file points into the main repository,
|
||||
// whose shared `info/exclude` the walker reads through `commondir`.
|
||||
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
|
||||
let main = f.root.parent().unwrap().join("main/.git");
|
||||
let worktree = main.join("worktrees/proj");
|
||||
std::fs::create_dir_all(main.join("info")).unwrap();
|
||||
std::fs::create_dir_all(&worktree).unwrap();
|
||||
std::fs::write(main.join("HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
std::fs::write(worktree.join("HEAD"), "ref: refs/heads/wt\n").unwrap();
|
||||
std::fs::write(worktree.join("commondir"), "../..\n").unwrap();
|
||||
std::fs::write(f.root.join(".git"), format!("gitdir: {}\n", worktree.display())).unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 2);
|
||||
drop(idx);
|
||||
|
||||
std::fs::write(main.join("info/exclude"), "generated/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "the shared exclude file 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 +923,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"]);
|
||||
}
|
||||
@@ -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, Vec<StoredSymbol>)> = records
|
||||
.into_par_iter()
|
||||
.filter(|(rel, _)| {
|
||||
let path = root.join(rel);
|
||||
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
|
||||
})
|
||||
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
|
||||
.collect();
|
||||
let mut d = self.data.write();
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -347,6 +556,36 @@ impl Shared {
|
||||
self.persist(&puts, &[], false);
|
||||
}
|
||||
|
||||
fn schedule_snapshot(&self) {
|
||||
if self.store.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut due = self.snapshot_due.lock();
|
||||
if due.is_none() {
|
||||
*due = Some(Instant::now() + SNAPSHOT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_snapshot_now(&self) {
|
||||
let dirs: Vec<PathBuf> = self.pending_dirs.lock().drain().collect();
|
||||
if !dirs.is_empty() {
|
||||
// Reconcile the directories touched since the last scan before
|
||||
// recording their mtimes; otherwise a missed watcher event could be
|
||||
// blessed into the manifest.
|
||||
self.scan(Some(&dirs), None, SystemTime::now());
|
||||
return;
|
||||
}
|
||||
if let Some(store) = &self.store {
|
||||
let filter = self.filter();
|
||||
let guard = SystemTime::now();
|
||||
let d = self.data.read();
|
||||
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
|
||||
tracing::warn!("index snapshot: {e}");
|
||||
}
|
||||
}
|
||||
*self.snapshot_due.lock() = None;
|
||||
}
|
||||
|
||||
fn persist(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) {
|
||||
if let Some(store) = &self.store
|
||||
&& let Err(e) = store.apply(puts, deletes, durable)
|
||||
@@ -406,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();
|
||||
@@ -420,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();
|
||||
}
|
||||
@@ -522,6 +772,7 @@ impl Shared {
|
||||
if touched {
|
||||
self.persist(&[], &[], true);
|
||||
self.emit_state();
|
||||
self.schedule_snapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,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")),
|
||||
@@ -548,6 +549,7 @@ mod tests {
|
||||
"Groovy",
|
||||
"Haskell",
|
||||
"Haxe",
|
||||
"Helm Template",
|
||||
"HXML",
|
||||
"HTTP",
|
||||
"IDL",
|
||||
@@ -841,6 +843,30 @@ mod tests {
|
||||
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
|
||||
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
|
||||
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
|
||||
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
|
||||
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
|
||||
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
|
||||
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
|
||||
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
|
||||
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
|
||||
assert_eq!(detect("common.gotmpl"), "helm-template");
|
||||
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
|
||||
assert_ne!(detect("site/index.tpl"), "helm-template");
|
||||
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
|
||||
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_django_fixture_has_templates_and_a_page() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -218,6 +218,37 @@ impl ClientHandler {
|
||||
true
|
||||
}
|
||||
|
||||
/// Ends every running task of a tsserver that died at its heap limit: the process
|
||||
/// behind them is gone, so no `end` will ever arrive. Without this the tasks sit
|
||||
/// as zombies until [`PROGRESS_STALE`] and the server stays "loading", holding
|
||||
/// empty answers back for [`LOADING_WAIT`] — while vtsls already restarts
|
||||
/// tsserver, which re-reports its own loading.
|
||||
fn tsserver_died(&self, mgr: &LspManager) {
|
||||
let events: Vec<IdeEvent> = {
|
||||
let mut progress = self.server.progress.lock();
|
||||
progress
|
||||
.drain()
|
||||
.map(|(_, p)| IdeEvent::Task {
|
||||
id: p.task,
|
||||
title: p.title,
|
||||
message: p.message,
|
||||
fraction: Some(1.0),
|
||||
done: true,
|
||||
cancellable: false,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let ended = !events.is_empty();
|
||||
for event in events {
|
||||
self.server.emit(mgr.sink(), event);
|
||||
}
|
||||
if ended {
|
||||
self.server.refresh_busy_status(mgr.sink());
|
||||
self.server.progress_ended();
|
||||
}
|
||||
self.server.set_loading(mgr.sink(), false);
|
||||
}
|
||||
|
||||
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
|
||||
// Problems come only from servers used for `diagnostics`.
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
@@ -391,6 +422,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 {
|
||||
@@ -406,6 +441,10 @@ impl Handler for ClientHandler {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
|
||||
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
// The tasks of the dead tsserver never see their `end`: end them now
|
||||
// instead of letting them sit as zombies until the stale window, and
|
||||
// stop holding queries back — the restarted instance re-reports.
|
||||
self.tsserver_died(&mgr);
|
||||
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
|
||||
mgr.sink().emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
@@ -563,7 +602,12 @@ fn merge(base: &mut Value, layer: Value) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tsserver_ran_out_of_memory;
|
||||
use super::*;
|
||||
use crate::server::Progress;
|
||||
use ide_core::event::SharedSink;
|
||||
use ide_core::{LanguageId, TaskId};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn tsserver_aborts_are_out_of_memory() {
|
||||
@@ -575,4 +619,85 @@ mod tests {
|
||||
assert!(!tsserver_ran_out_of_memory("Code: 134."));
|
||||
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
|
||||
}
|
||||
|
||||
/// A vtsls server with two running tasks, one marked stalled, and `loading` set:
|
||||
/// the state a tsserver OOM leaves behind.
|
||||
fn loaded_vtsls(events: Arc<Mutex<Vec<IdeEvent>>>) -> (Arc<LspManager>, Arc<crate::server::Server>) {
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |event: IdeEvent| events.lock().unwrap().push(event))
|
||||
};
|
||||
let languages = Arc::new(ide_language::LanguageRegistry::from_configs(vec![]));
|
||||
let mgr = LspManager::new(PathBuf::from("/p"), languages, sink);
|
||||
let cfg: ide_language::LanguageServerConfig =
|
||||
toml::from_str("name = \"vtsls\"\ncommand = \"vtsls\"\ntsserver-project = true").unwrap();
|
||||
let server = Arc::new(crate::server::Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")));
|
||||
server.progress.lock().insert(
|
||||
"loading".into(),
|
||||
Progress {
|
||||
task: TaskId::next(),
|
||||
title: "Initializing 'tsconfig.json'".into(),
|
||||
message: None,
|
||||
cancel: None,
|
||||
last: tokio::time::Instant::now(),
|
||||
fraction: Some(0.1),
|
||||
stalled: false,
|
||||
},
|
||||
);
|
||||
server.progress.lock().insert(
|
||||
"stuck".into(),
|
||||
Progress {
|
||||
task: TaskId::next(),
|
||||
title: "Analyzing 'index.vue' and its dependencies".into(),
|
||||
message: None,
|
||||
cancel: None,
|
||||
last: tokio::time::Instant::now(),
|
||||
fraction: Some(0.2),
|
||||
stalled: true,
|
||||
},
|
||||
);
|
||||
let quiet: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
server.set_loading(&quiet, true);
|
||||
(mgr, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsserver_dying_ends_every_task_and_the_loading_state() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let (mgr, server) = loaded_vtsls(events.clone());
|
||||
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
|
||||
|
||||
handler.tsserver_died(&mgr);
|
||||
|
||||
// Both tasks ended (done: true), including the stalled one; none left running.
|
||||
let tasks: Vec<_> = events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(tasks.len(), 2, "{tasks:?}");
|
||||
assert!(tasks.iter().all(|(_, done)| *done), "{tasks:?}");
|
||||
assert!(server.progress.lock().is_empty());
|
||||
assert!(!server.is_loading());
|
||||
assert!(!server.is_busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsserver_dying_without_tasks_only_clears_loading() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let (mgr, server) = loaded_vtsls(events.clone());
|
||||
server.progress.lock().clear();
|
||||
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
|
||||
|
||||
handler.tsserver_died(&mgr);
|
||||
|
||||
assert!(events.lock().unwrap().is_empty());
|
||||
assert!(!server.is_loading());
|
||||
// A status event may be published, but no task events.
|
||||
assert!(server.progress.lock().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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,68 @@ 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'}');
|
||||
|
||||
/// Fields of LSP messages whose value is a URI.
|
||||
const URI_FIELDS: [&str; 9] =
|
||||
["uri", "targetUri", "newUri", "oldUri", "baseUri", "scopeUri", "rootUri", "target", "href"];
|
||||
|
||||
/// Percent-encodes the characters URIs don't allow in the `file:` URIs of `value`: the URI
|
||||
/// fields and the keys of a workspace edit's `changes`. Eclipse's jdtls sends a path as is
|
||||
/// (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response. Other
|
||||
/// strings stay as sent: an edit's `newText` may well start with `file:` (YAML, `.env`).
|
||||
pub fn encode_loose_file_uris(value: &mut Value) {
|
||||
match value {
|
||||
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
|
||||
Value::Object(map) => {
|
||||
for (key, value) in map.iter_mut() {
|
||||
match value {
|
||||
Value::String(s) if URI_FIELDS.contains(&key.as_str()) => {
|
||||
if let Some(encoded) = encode_loose(s) {
|
||||
*s = encoded;
|
||||
}
|
||||
}
|
||||
Value::Object(changes) if key == "changes" => {
|
||||
if changes.keys().any(|k| encode_loose(k).is_some()) {
|
||||
*changes = std::mem::take(changes)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (encode_loose(&k).unwrap_or(k), v))
|
||||
.collect();
|
||||
}
|
||||
changes.values_mut().for_each(encode_loose_file_uris);
|
||||
}
|
||||
_ => encode_loose_file_uris(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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 +159,47 @@ 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!({ "uri": "file:///a%20b/%E9%A1%B9.java" });
|
||||
encode_loose_file_uris(&mut encoded);
|
||||
assert_eq!(encoded["uri"], "file:///a%20b/%E9%A1%B9.java");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_starts_with_file_is_not_a_uri() {
|
||||
// A YAML formatting edit and a completion whose text begins with `file:`.
|
||||
let mut v = serde_json::json!({ "result": [
|
||||
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 1, "character": 0 } }, "newText": "file: a b\n - c\n" },
|
||||
{ "label": "file:", "insertText": "file: ${1:path}", "documentation": "file: the input" }
|
||||
], "edit": { "changes": { "file:///tmp/a b.yaml": [{ "newText": "file: x y" }] } } });
|
||||
encode_loose_file_uris(&mut v);
|
||||
assert_eq!(v["result"][0]["newText"], "file: a b\n - c\n");
|
||||
assert_eq!(v["result"][1]["insertText"], "file: ${1:path}");
|
||||
assert_eq!(v["result"][1]["documentation"], "file: the input");
|
||||
let changes = &v["edit"]["changes"]["file:///tmp/a%20b.yaml"];
|
||||
assert_eq!(changes[0]["newText"], "file: x y");
|
||||
// A diagnostic's help link is a URI too.
|
||||
let mut d = serde_json::json!({ "codeDescription": { "href": "file:///tmp/help files/rule.html" } });
|
||||
encode_loose_file_uris(&mut d);
|
||||
assert_eq!(d["codeDescription"]["href"], "file:///tmp/help%20files/rule.html");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The discovery file, relative to the app data directory.
|
||||
@@ -39,6 +40,9 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Discovery {
|
||||
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
|
||||
#[serde(default = "loopback")]
|
||||
pub address: IpAddr,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
/// The IDE's process.
|
||||
@@ -47,6 +51,10 @@ pub struct Discovery {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
fn loopback() -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
pub fn read(path: &Path) -> io::Result<Self> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
@@ -103,7 +111,13 @@ mod tests {
|
||||
fn round_trips_privately_and_only_its_owner_removes_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = discovery_path(tmp.path());
|
||||
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
|
||||
let ours = Discovery {
|
||||
address: "100.92.72.95".parse().unwrap(),
|
||||
port: 1234,
|
||||
token: "t".into(),
|
||||
pid: std::process::id(),
|
||||
version: "0.1.0".into(),
|
||||
};
|
||||
ours.write(&path).unwrap();
|
||||
assert_eq!(Discovery::read(&path).unwrap(), ours);
|
||||
#[cfg(unix)]
|
||||
@@ -118,6 +132,10 @@ mod tests {
|
||||
ours.write(&path).unwrap();
|
||||
Discovery::remove_if_ours(&path);
|
||||
assert!(!path.exists());
|
||||
|
||||
// The file of an IDE that listened on 127.0.0.1 only has no address.
|
||||
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
|
||||
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! the IDE's built-in tools.
|
||||
//!
|
||||
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
|
||||
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
|
||||
//! clients too) on `127.0.0.1:<random port>` (or another address of
|
||||
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
|
||||
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
|
||||
//! `Authorization: Bearer <token>` (random, per run); requests from another
|
||||
//! `Origin` or `Host` are refused (DNS rebinding).
|
||||
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
|
||||
//! names a folder; the server picks the open project containing it.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
|
||||
//! `version`; readable only by the user) while the server runs.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
|
||||
//! `pid`, `version`; readable only by the user) while the server runs.
|
||||
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
|
||||
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
|
||||
//! persisted client configurations hold no token.
|
||||
@@ -23,7 +24,7 @@ mod server;
|
||||
pub mod stdio;
|
||||
|
||||
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
|
||||
pub use server::{McpServer, ServerOptions};
|
||||
pub use server::{McpServer, ServerOptions, local_address};
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -85,9 +86,15 @@ pub trait ToolRegistry: Send + Sync + 'static {
|
||||
) -> Result<String, ToolCallError>;
|
||||
}
|
||||
|
||||
/// The URL of the server at `port` for `project`.
|
||||
/// The URL of the server at `127.0.0.1:<port>` for `project`.
|
||||
pub fn endpoint_url(port: u16, project: &Path) -> String {
|
||||
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
|
||||
}
|
||||
|
||||
/// The URL of the server at `<address>:<port>` for `project`.
|
||||
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
|
||||
let at = std::net::SocketAddr::new(address, port);
|
||||
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
|
||||
@@ -133,6 +140,9 @@ mod tests {
|
||||
fn urls_and_commands_name_the_project() {
|
||||
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
|
||||
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
|
||||
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
|
||||
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
|
||||
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
|
||||
assert_eq!(
|
||||
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
|
||||
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
|
||||
|
||||
@@ -9,6 +9,7 @@ use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -40,6 +41,8 @@ type Body = Either<Full<Bytes>, EventStream>;
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
token: String,
|
||||
/// The address listened on (`0.0.0.0` / `::`: every address).
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
requests: protocol::Requests,
|
||||
cancel: CancellationToken,
|
||||
@@ -48,6 +51,7 @@ struct Shared {
|
||||
/// The running server. Stopping it (or dropping it) closes the port and
|
||||
/// removes its discovery file.
|
||||
pub struct McpServer {
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
/// Why the port asked for in [`ServerOptions::port`] was not used.
|
||||
port_unavailable: Option<String>,
|
||||
@@ -60,7 +64,10 @@ pub struct McpServer {
|
||||
/// How [`McpServer::start_with`] listens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerOptions {
|
||||
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
|
||||
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
|
||||
pub address: Option<IpAddr>,
|
||||
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
|
||||
/// `None`: a free port.
|
||||
pub port: Option<u16>,
|
||||
@@ -86,15 +93,20 @@ impl McpServer {
|
||||
runtime: &tokio::runtime::Handle,
|
||||
options: ServerOptions,
|
||||
) -> std::io::Result<Self> {
|
||||
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
let bind = |port: u16| {
|
||||
std::net::TcpListener::bind(SocketAddr::new(address, port))
|
||||
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
|
||||
};
|
||||
let (listener, port_unavailable) = match options.port {
|
||||
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Some(port) => match bind(port) {
|
||||
Ok(listener) => (listener, None),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
|
||||
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
|
||||
(bind(0)?, Some(format!("port {port}: {e}")))
|
||||
}
|
||||
},
|
||||
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
|
||||
None => (bind(0)?, None),
|
||||
};
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
@@ -108,6 +120,7 @@ impl McpServer {
|
||||
};
|
||||
if let Some(path) = &discovery {
|
||||
let info = Discovery {
|
||||
address: local_address(address),
|
||||
port,
|
||||
token: token.clone(),
|
||||
pid: std::process::id(),
|
||||
@@ -120,13 +133,19 @@ impl McpServer {
|
||||
let shared = Arc::new(Shared {
|
||||
registry,
|
||||
token: token.clone(),
|
||||
address,
|
||||
port,
|
||||
requests: Default::default(),
|
||||
cancel: cancel.clone(),
|
||||
});
|
||||
runtime.spawn(accept(listener, shared, stopped));
|
||||
tracing::info!("MCP server on 127.0.0.1:{port}");
|
||||
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
|
||||
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
}
|
||||
|
||||
/// The address listened on (`0.0.0.0` / `::`: every address).
|
||||
pub fn address(&self) -> IpAddr {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
@@ -143,14 +162,24 @@ impl McpServer {
|
||||
&self.token
|
||||
}
|
||||
|
||||
/// The endpoint URL for `project`.
|
||||
/// The endpoint URL for `project`, at the address a client on this computer connects to.
|
||||
pub fn url(&self, project: &Path) -> String {
|
||||
crate::endpoint_url(self.port, project)
|
||||
crate::endpoint_url_at(local_address(self.address), self.port, project)
|
||||
}
|
||||
|
||||
pub fn stop(self) {}
|
||||
}
|
||||
|
||||
/// The address a client on this computer connects to for a server listening on `address`:
|
||||
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
|
||||
pub fn local_address(address: IpAddr) -> IpAddr {
|
||||
match address {
|
||||
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
a => a,
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpServer {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.cancel();
|
||||
@@ -212,7 +241,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
if req.uri().path() != "/mcp" {
|
||||
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
|
||||
}
|
||||
if !local_request(req.headers(), shared.port) {
|
||||
if !local_request(req.headers(), shared.address, shared.port) {
|
||||
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
|
||||
}
|
||||
if !authorized(req.headers(), &shared.token) {
|
||||
@@ -343,11 +372,16 @@ impl hyper::body::Body for EventStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// No `Origin` other than this server's own, and a loopback `Host`: a web
|
||||
/// page (DNS rebinding) can't reach the tools even before the token check.
|
||||
fn local_request(headers: &HeaderMap, port: u16) -> bool {
|
||||
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
|
||||
/// (loopback, the address listened on, or any when listening on every address) or as
|
||||
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
|
||||
/// even before the token check.
|
||||
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
|
||||
let ours = |authority: &str| {
|
||||
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
|
||||
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|
||||
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
|
||||
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
|
||||
})
|
||||
};
|
||||
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
|
||||
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
|
||||
@@ -534,7 +568,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
fn only_hosts_and_origins_of_this_server_pass() {
|
||||
let headers = |pairs: &[(header::HeaderName, &str)]| {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
@@ -542,16 +576,43 @@ mod tests {
|
||||
}
|
||||
h
|
||||
};
|
||||
assert!(local_request(&headers(&[]), 80));
|
||||
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
|
||||
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
||||
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
|
||||
assert!(local_request(&headers(&[]), loopback, 80));
|
||||
assert!(host("127.0.0.1:80", loopback));
|
||||
assert!(host("[::1]:80", loopback));
|
||||
assert!(local_request(
|
||||
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
|
||||
loopback,
|
||||
80
|
||||
));
|
||||
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
|
||||
assert!(!host("evil.example:80", loopback));
|
||||
assert!(!host("127.0.0.1:81", loopback));
|
||||
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
|
||||
|
||||
// Listening on one address: that address too, still never a host name.
|
||||
let lan: IpAddr = "100.92.72.95".parse().unwrap();
|
||||
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
|
||||
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
|
||||
// Listening on every address: any IP address, still never a host name.
|
||||
let every: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
|
||||
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
|
||||
assert!(local_request(
|
||||
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
|
||||
every,
|
||||
80
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
|
||||
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
|
||||
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
|
||||
assert_eq!(local_address(ip("::")), ip("::1"));
|
||||
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -148,13 +148,14 @@ async fn post(
|
||||
)
|
||||
};
|
||||
let info = Discovery::read(discovery).map_err(|_| not_running())?;
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
|
||||
let at = std::net::SocketAddr::new(info.address, info.port);
|
||||
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
|
||||
tokio::spawn(conn);
|
||||
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
|
||||
.header(header::HOST, format!("127.0.0.1:{}", info.port))
|
||||
.header(header::HOST, at.to_string())
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
|
||||
.header("mcp-session-id", scope)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
|
||||
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
|
||||
s.server.take().unwrap().stop();
|
||||
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let token = "ab".repeat(32);
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
s.server =
|
||||
@@ -393,3 +393,34 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
|
||||
second.port_unavailable()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
|
||||
let mut s = setup();
|
||||
s.server.take().unwrap().stop();
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
|
||||
// Every address: clients here use loopback; other computers name the server by an address of it.
|
||||
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
|
||||
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
|
||||
let port = s.server().port();
|
||||
assert_eq!(s.server().address().to_string(), "0.0.0.0");
|
||||
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
|
||||
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
|
||||
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
|
||||
let request = |host: String| {
|
||||
format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
|
||||
bearer(&s),
|
||||
ping.len()
|
||||
)
|
||||
};
|
||||
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
|
||||
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
|
||||
|
||||
// Not an address of this computer: the server does not start, and says which address.
|
||||
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
|
||||
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
|
||||
assert!(error.to_string().contains("192.0.2.1"), "{error}");
|
||||
}
|
||||
@@ -234,6 +234,11 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (path, set, item);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Replace all `(path, icon)` entries of a tree decoration set at once (API 1.8).
|
||||
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
let _ = (set, items);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Trusted repository history.
|
||||
fn vcs_log(
|
||||
&self,
|
||||
|
||||
@@ -151,6 +151,67 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an already-open document for a background edit without following a
|
||||
/// symbolic link, junction, or other Windows reparse point. Unlike ordinary
|
||||
/// project reads, background edits may have originated with another machine:
|
||||
/// accepting a link would let that machine change an unexpected file when the
|
||||
/// user later saves the editor buffer.
|
||||
///
|
||||
/// The input is deliberately project-relative. Existing path components are
|
||||
/// examined with `symlink_metadata` from the canonical project root; no
|
||||
/// canonicalization happens after the walk, so a link is never traversed.
|
||||
pub fn resolve_background_document_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() || Path::new(path).is_absolute() {
|
||||
return Err(format!("{path}: background document paths must be project-relative"));
|
||||
}
|
||||
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let components: Vec<_> = Path::new(path).components().collect();
|
||||
if components.is_empty() || components.iter().any(|part| !matches!(part, std::path::Component::Normal(_))) {
|
||||
return Err(format!("{path}: background document paths must not leave the project"));
|
||||
}
|
||||
let mut current = root.clone();
|
||||
for (index, part) in components.iter().enumerate() {
|
||||
let std::path::Component::Normal(name) = part else { unreachable!() };
|
||||
if background_control_name(name) {
|
||||
return Err(format!("{path}: background edits may not target VCS or IDE control files"));
|
||||
}
|
||||
current.push(name);
|
||||
let metadata = std::fs::symlink_metadata(¤t).map_err(|_| format!("{path}: no such open document"))?;
|
||||
if is_link_or_reparse(&metadata) {
|
||||
return Err(format!("{path}: background edits refuse symbolic links"));
|
||||
}
|
||||
if index + 1 < components.len() && !metadata.is_dir() {
|
||||
return Err(format!("{path}: not a directory"));
|
||||
}
|
||||
if index + 1 == components.len() && !metadata.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// [`resolve_background_document_in_root`] over a workspace group's roots.
|
||||
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
|
||||
first_root_resolving(roots, path, resolve_background_document_in_root)
|
||||
}
|
||||
|
||||
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
let reparse = {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let reparse = false;
|
||||
metadata.file_type().is_symlink() || reparse
|
||||
}
|
||||
|
||||
fn background_control_name(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
|
||||
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
|
||||
}
|
||||
|
||||
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
|
||||
/// without requiring it to exist: `Ok(true)` also for a missing path, so
|
||||
/// callers distinguish "outside" from "absent". Existing symlinks are
|
||||
@@ -705,6 +766,25 @@ mod tests {
|
||||
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_document_edits_are_relative_and_exclude_control_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(root.join(".openidea/state"), "state\n").unwrap();
|
||||
|
||||
assert!(resolve_background_document_in_root(&root, "src/main.rs").is_ok());
|
||||
for path in
|
||||
["/etc/passwd", "../outside", "src/../.git/hooks/pre-commit", ".git/hooks/pre-commit", ".openidea/state"]
|
||||
{
|
||||
assert!(resolve_background_document_in_root(&root, path).is_err(), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
|
||||
for path in [
|
||||
@@ -740,6 +820,23 @@ mod tests {
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn background_document_edits_refuse_final_and_ancestor_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
symlink(root.join("src/main.rs"), root.join("final-link.rs")).unwrap();
|
||||
symlink(root.join("src"), root.join("linked-src")).unwrap();
|
||||
|
||||
assert!(resolve_background_document_in_root(&root, "final-link.rs").unwrap_err().contains("symbolic links"));
|
||||
assert!(
|
||||
resolve_background_document_in_root(&root, "linked-src/main.rs").unwrap_err().contains("symbolic links")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_paths_follow_existing_and_dangling_symlinks() {
|
||||
|
||||
@@ -932,6 +932,9 @@ pub struct FileDecoration {
|
||||
/// Text shown after the name (API 1.7 `set-file-note`).
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
/// `[[contributes.fileIcons]]` id for this file or folder (API 1.8 `set-file-icon`).
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
/// Commit metadata, date in Unix seconds.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
|
||||
return Ok(Err(wit::Error::Invalid("edits too large".into())));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
let path = match self.resolve_in_project(&root, &path) {
|
||||
let path = match self.resolve_background_document_in_project(&root, &path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
|
||||
};
|
||||
@@ -936,6 +936,14 @@ impl StoreData {
|
||||
sandbox::resolve_writable_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
|
||||
})
|
||||
}
|
||||
/// A background document edit is the only plugin operation that changes an
|
||||
/// editor on behalf of a remote peer. It therefore rejects links and IDE
|
||||
/// control directories instead of using the normal read resolver.
|
||||
fn resolve_background_document_in_project(&self, root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
sandbox::resolve_background_document_in_root(root, path).or_else(|root_error| {
|
||||
sandbox::resolve_background_document_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
|
||||
})
|
||||
}
|
||||
/// Whether a `project` resource's path lands inside the resource's root or
|
||||
/// any member of its workspace group, without requiring it to exist.
|
||||
fn inside_project(&self, root: &Path, path: &str) -> Result<bool, String> {
|
||||
@@ -2497,7 +2505,13 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
.file_decorations(
|
||||
Some(&real),
|
||||
&set_id,
|
||||
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
|
||||
Some(crate::FileDecoration {
|
||||
badge: item.badge,
|
||||
style: item.style,
|
||||
tooltip: item.tooltip,
|
||||
note: None,
|
||||
icon: None,
|
||||
}),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
@@ -2515,9 +2529,36 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
fn set_file_icon(&mut self, path: String, set_id: String, icon: String, tooltip: Option<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let real = match self.decoration_path(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: None, icon: Some(icon) };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
fn set_file_icons(&mut self, set_id: String, items: Vec<(String, String)>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if items.len() > 10_000 {
|
||||
return Ok(Err(wit::Error::Invalid("at most 10000 file icons per set".into())));
|
||||
}
|
||||
let mut resolved = Vec::with_capacity(items.len());
|
||||
for (path, icon) in items {
|
||||
match self.decoration_path(&path) {
|
||||
Ok(real) => resolved.push((real, icon)),
|
||||
Err(e) => return Ok(Err(e)),
|
||||
}
|
||||
}
|
||||
Ok(self.host.file_icon_decorations(&set_id, resolved).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod language_features_bindings {
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
|
||||
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
|
||||
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
|
||||
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
|
||||
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
|
||||
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
|
||||
@@ -39,12 +39,8 @@ impl Plugin for Fixture {
|
||||
hover: None,
|
||||
after: Some(after.into()),
|
||||
};
|
||||
host::set_decorations(
|
||||
path,
|
||||
"fixture",
|
||||
&[item("typo", "typo message"), item("weak", "weak message")],
|
||||
)
|
||||
.map(|()| None)
|
||||
host::set_decorations(path, "fixture", &[item("typo", "typo message"), item("weak", "weak message")])
|
||||
.map(|()| None)
|
||||
}
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
|
||||
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
|
||||
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
|
||||
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
|
||||
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
|
||||
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
|
||||
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
|
||||
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
|
||||
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
|
||||
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
|
||||
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
|
||||
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
|
||||
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
|
||||
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
|
||||
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
|
||||
Loaded 100 of 489 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача