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

...
29 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai 009e61d27b fix(editor): clicking the author hint again closes the blame annotations (#3448)
The Code Vision author hint ran Annotate with Git Blame only while the annotations were hidden, so a second click left them open. It now runs vcs.annotate on every click, toggling the annotations like the action itself.
2026-11-15 03:46:11 +08:00
l0ng-ai a89c0dd229 feat(ui): small raster data-URI images as icons (#3446)
Icon draws a raster data: URI (PNG, JPEG, GIF or WebP, base64, at most 64 KiB) as an image of the icon's size, so plugin view items and buttons can show avatars and status badges the plugin fetched with its net grant; the UI never loads URLs for icons, and older hosts show the file icon. Documented in the views guide.
2026-11-15 03:33:29 +08:00
l0ng-ai ea644dcdb0 feat(plugins): diffs of up to 8 MiB per side from open-diff (#3445)
views.open-diff and content tab reply diffs accept 8 MiB per side (was 1 MiB). A larger side fails with invalid("diff too large: the <side> side is over 8 MiB") from the host call and "Diff too large" for a web view's open-diff message, so plugins can tell the size limit from a malformed request. WIT docs updated and plugin components rebuilt.
2026-11-15 03:32:29 +08:00
l0ng-ai 519c8ad8fc feat(plugins): Markdown preview for Markdown content tabs (#3442)
A Markdown content tab (language markdown, or a .md file name) gets the Markdown editor's layouts (Editor Only / Editor and Preview / Preview Only) and opens on the preview when read-only. The preview uses the editor's sanitized Markdown pipeline, highlights fenced code, follows reloaded content and opens web links in the browser. No API change; documented in the plugin README and views guide.
2026-11-15 03:30:52 +08:00
l0ng-ai 27fdbe71e5 fix(ui): leave language status widgets out of projects without the language (#3439)
The status bar context menu listed PHP Language Level and Python Interpreter in every project, although neither widget shows without PHP or Python files. Status widgets can now be marked unavailable for the open project (setStatusWidgetAvailable): neither rendered nor listed in the menus. PHP and Python track availability from activation (so hidden widgets are judged too) and re-check on project, index, file-set and settings changes.
2026-11-15 03:01:18 +08:00
l0ng-ai d5cd5e7a55 feat(perf): CPU, PID and End Process in the memory popup (#3437)
The Memory & Diagnostics popup now shows each row's CPU use since the previous measurement (percent of one core; proc_pid_rusage / /proc/<pid>/stat / GetProcessTimes via ide-workspace CpuSampler), the PID in the row tooltip, and an End Process button for language server, run and terminal rows. end_process_tree sends SIGTERM, then SIGKILL after a grace period (TerminateProcess on Windows), and refuses pids that don't descend from the backend; owners see the exit as usual (language servers restart, terminals close).
2026-11-15 02:55:37 +08:00
l0ng-ai c32a68c0b8 feat(ui): Project Structure toolbars and the type hierarchy root's package (#3436)
Project Structure: Modules gets an icon toolbar (+ menu with Add Module Overrides / Import Module from Existing Sources, -); Libraries gets + (JAR or Directory, Classes Folder), - and Copy. Type Hierarchy: the root type shows the server's detail (Java: its package) beside its name, through the additive lsp_prepare_type_hierarchy command.

Part of #3409
2026-11-15 02:29:04 +08:00
pravdomil 59cc84521f fix(project-tree): Autoscroll to Source no longer opens files in external apps (#3429)
Autoscroll to Source used the same EditorService.openFile as an explicit open, so files matching Files opened in associated applications (PDF, Office documents) were launched in the system app whenever the selection touched them. OpenOptions.external = false now leaves such files unopened; ExternalFileOpenHandler gains matches() so the editor can tell without launching anything. Explicit opens (double-click, Enter, Jump to Source) are unchanged.

Co-authored-by: pravdomil <[email protected]>
2026-11-15 02:15:18 +08:00
l0ng-ai 4faa37aaea fix(vcs): Pull, Merge and Rebase start on the repository of the context (#3435)
The context menus' Git > Pull... ran Update Project (every repository) while the rest of the submenu acts on the selection. It now opens the Pull dialog. Pull, Merge and Rebase start on the repository of the action's files, else the focused Project view selection, else the active editor's file, else the selected repository; Update Project still updates every repository.

Closes #3404
2026-11-15 02:13:34 +08:00
l0ng-ai aaf25d9f0d fix(vcs): keep the Project view branch suffix of workspace members current (#3434)
Only the project folder's watcher emitted VcsChanged on .git changes, so members outside the workspace folder kept a stale branch suffix after a checkout, fetch, pull or push. Member watchers now emit it too (same own-index-write filter), and the suffix is read again when a repository's HEAD moves or a fetch / pull / push finishes.

Closes #3405
2026-11-15 02:13:23 +08:00
l0ng-ai 913ad6616c feat(vcs): View Commits after Update Project opens an Update Info tab (#3432)
View Commits opened a modal dialog with one repository's commits. It now opens a closable Update Info tab in the Git tool window: the Log of every updated repository, each limited to the range the update brought in (LogFilters.repoRanges, sent per repository as the query's ranges), with the Log's filters, details and changed files; each update opens a tab of its own.

Closes #3403
2026-11-15 02:00:12 +08:00
l0ng-ai b80f0f9e39 fix(ui): collapsing a tree node also collapses everything expanded below it (#3430)
Collapsing a node kept its expanded descendants in the expanded set, so expanding it again brought the whole subtree back. The shared Tree now drops the node's loaded descendants when it collapses (chevron, Left, Enter, single-click expand), so every tree (Project view, Structure, Git, Debugger variables, ...) shows only direct children on the next expand; trees that persist their expanded set save the result.

Closes #3402
2026-11-15 01:48:20 +08:00
l0ng-ai 460027fd4b feat(layout): Restore Current Layout and Save Changes in Current Layout (#3421)
Restore Current Layout applies the active saved tool window layout again, or the default layout when none is active; Shift+F12 in the IntelliJ keymaps now runs it. Save Changes in Current Layout stores the current windows into the active layout. Both are in Window > Tool Window Layouts.

Closes #3399
2026-11-15 01:28:16 +08:00
Muuтаmuutot 33c5627bdb feat(analysis): Python inheritance inspections from the IDE graph (#3419)
Four Python inspections under a new Python category in Settings › Inspections, computed from the IDE's inheritance graph with facts the backend extracts from the syntax tree (doc_python_facts): circular inheritance (error), @override on a method no base declares (warning), abstract method not implemented (warning), redundant override (weak warning). A rule stays silent while a base on the lookup path is outside the project; object, ABC, Generic and Protocol count as known. A class that is itself abstract (own abstract methods, or ABC / Protocol as a base) isn't asked to implement inherited abstract methods.

Co-authored-by: muutot <[email protected]>
2026-11-15 01:20:26 +08:00
akshathayadav378-boopтаakshathayadav378-boop 3a22a2d0f0 fix(vcs): show cached inline blame without the 300 ms debounce (#3410)
A caret move in a document whose blame is already loaded shows the new line's inline blame at once (BlameCache keeps the resolved lines and peek(doc) returns them for the current document version); edits and first loads keep the 300 ms debounce, so typing starts at most one git blame per pause. This replaces the equivalent change merged in #3418.

Co-authored-by: akshathayadav378-boop <[email protected]>
2026-11-15 01:11:43 +08:00
l0ng-ai c169b8ab3c fix(vcs): show the current line's inline blame at once when the file is blamed (#3418)
A caret move within a document whose blame is loaded (or loading) now shows the new line's inline blame at once; edits and a document not blamed yet still wait 300 ms, so typing doesn't run git per keystroke.
2026-11-15 01:10:23 +08:00
l0ng-ai 0dbce5d09e feat(vcs): show the number of changed files in the Project view's branch suffix (#3416)
With "Show branch and incoming/outgoing commits in the Project view" on, a repository root with local changes also shows how many files changed (staged, unstaged or untracked; ignored aside), with a tooltip; a clean repository shows nothing extra. The count follows the working tree status, re-rendered from the branches already read without asking git again.
2026-11-15 01:08:47 +08:00
l0ng-ai 7fe6f5f58d feat(run): run icons on the profiles of launchSettings.json (#3415)
A .NET project's Properties/launchSettings.json shows a run icon on each Project profile; Run / Debug launch the project's configuration for that profile, or a temporary copy for a profile added since detection ran. The package.json scripts gutter is generalised into entryGutter for this.
2026-11-15 01:04:59 +08:00
l0ng-ai abf904553d feat(build): build .NET solutions and projects in the Build tool window (#3414)
Build Project / Build Module / Rebuild / Build Artifacts build a .NET solution or project (dotnet build, --no-incremental, dotnet publish), and MSBuild's canonical file(line,col): error CODE lines become Build messages linking to the source. A .NET run configuration's "Build .NET project" before-launch task builds its project in the Build tool window instead of a Run tab.
2026-11-15 01:00:28 +08:00
l0ng-ai d425388f1a fix(run): read .NET launch profiles from a launchSettings.json with a byte order mark (#3412)
Visual Studio saves launchSettings.json with a UTF-8 byte order mark, which serde_json rejects, so a project's launch profiles came out empty and a configuration naming one failed with "launch profile is missing". jsonc::strip drops a leading byte order mark, which also covers devcontainer.json, the .vscode/launch.json import, tsconfig.json and run detection.
2026-11-15 00:54:37 +08:00
Akbar Husain de123e2a21 perf(index): stream the cold scan instead of chunk joins (#3407)
The initial scan parsed files in 512-file chunks (rayon join, then insert and redb commit), so every join idled the pool on its slowest file and insert/persist serialized between chunks. The scan now parses the whole todo list in one rayon pass, serializes cache records inside the parse, and streams them over a bounded channel to the index thread, which inserts and persists in batches of 2048 alongside the parse. Records stay byte-identical; the scan's log line carries walk / classify / extract / insert / persist / durable laps.

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 00:48:27 +08:00
Muuтаmuutot d19dbec56a feat(editor): Python inheritance in navigation from one shared graph (#3397)
Python's servers answer no type hierarchy, so the navigation built on it did nothing for Python. The inheritance graph moves out of the gutter into src/editor/navigation/inheritanceGraph.ts (one fetch per project, the name map and base resolver), and Type Hierarchy, Structure's Show Inherited, Super Method and Go to Implementation use it for Python, merged with whatever the server answers. The gutter keeps its per-file import reading.

The subclass map is built when a subtype lookup first needs it; the files whose imports decide a base are read in one batch, bases resolve in parallel, and an import whose name is no class of the project is not looked up.

Co-authored-by: muutot <[email protected]>
2026-11-15 00:41:25 +08:00
l0ng-ai 1a204b5f4b fix(lsp): show TypeScript 7 as the server when its own tsc --lsp runs (#3408)
A project on TypeScript 7 runs the compiler's native tsc --lsp in place of vtsls / typescript-language-server, but the status bar and the server popup still named the configured server. The settings hook gives such a server a display name, carried by ServerInfo and the languageServer event as an optional label; the server's name stays its identity.
2026-11-15 00:25:51 +08:00
l0ng-ai 3ec7daf845 fix(profiler): name js-debug as the recorder of a page's CPU profile (#3406)
A page's CPU profile is read like a Node.js run's .cpuprofile files, so its snapshot header said "node --cpu-prof". profiler.showCpuProfiles takes an optional profiler name; the debugger passes js-debug.
2026-11-15 00:16:52 +08:00
ntbang0901 112ac05052 feat(debugger): replace the responses a debugged page receives (#3329)
A page session can answer requests itself: rules (URL part → status, content type, body) are kept per project, edited from the Network tab (Overrides, Override Response… on a request), and applied through js-debug's CDP proxy with Fetch.enable / Fetch.fulfillRequest. Interception is switched off before the connection is dropped, unknown statuses get a reason phrase, cross-origin pages get CORS headers and preflight answers, and rules present at page start wait for the launch response.

The Network list marks a request overridden only when it was sent after the rule that answers it was saved or enabled.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-15 00:15:09 +08:00
ntbang0901 10b7340e2a feat(debugger): CPU profile of a debugged page (#3320)
The More menu of a page session has Start / Stop CPU Profile (js-debug's startProfile / stopProfile); the recording opens in the Profiler tool window as a snapshot named after the session. The .cpuprofile reader prefers js-debug's $vscode.locations, so a frame opens the project's source at its line instead of the URL the browser ran.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-15 00:01:16 +08:00
Andrea Basileтаandreabasile 6fa5b368e6 feat(python): a Docker or Compose run target as the project interpreter (#3364)
A Docker or Compose run target can be the project's Python interpreter (languages.python.interpreter = "target:<id>"). The IDE runs the target's Python once in a new container (entrypoint and service dependencies skipped), copies its sys.path folders outside the standard library and the project with docker cp, once per image, and gives them to basedpyright / Pyright as extraPaths with an empty local virtualenv as interpreter. Packages show read-only with a refresh that copies again; Python run configurations without a target of their own run on that target; the copy shows under External Libraries. Snapshots require a trusted project.

Co-authored-by: andreabasile <[email protected]>
2026-11-14 23:53:40 +08:00
Mr0Bread dd40273952 test(database): cover result submission lifecycle interleavings (#3346)
Covers paging staying disabled while a remounted result applies edits, and an apply finishing after its result tab closed without refreshing another tab. (The refresh-failure case is already covered on main.)

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 23:43:23 +08:00
l0ng-ai 21bc81c6da fix(ui): remember splitter positions in the Git and other tool windows (#3401)
None of the Git views gave SplitGroup a storageKey, so their splitters reset whenever the view was recreated. They, the Commit dialog and the Todo, Find, Problems, Usages, Build, HTTP Client and Dependency Analyzer splits now store their sizes. A view whose preview comes and goes keys sizes per panel set, and SplitGroup applies that set's sizes when the key changes, without remounting the view.
2026-11-14 23:42:02 +08:00
280 змінених файлів з 8803 додано та 632 видалено

No files matched your search

Створено
+2
Переглянути файл
@@ -4269,6 +4269,7 @@ dependencies = [
name = "ide-dap"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"futures",
"ide-core",
"ide-heap",
@@ -4287,6 +4288,7 @@ dependencies = [
"specta",
"tempfile",
"tokio",
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
+51 -1
Переглянути файл
@@ -53,6 +53,8 @@ pub enum BuildSystem {
TypeScript,
#[serde(rename = "cmake")]
CMake,
#[serde(rename = "dotnet")]
Dotnet,
}
/// One command of a build.
@@ -165,6 +167,8 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
{
Some(BuildSystem::Gradle)
} else if dotnet_target(dir).is_some() {
Some(BuildSystem::Dotnet)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod", "go.work"]) {
@@ -225,7 +229,7 @@ pub fn plan(
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| {
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
IdeError::not_found("no supported build system (Maven, Gradle, .NET, Cargo, Go, TypeScript, CMake) found")
})?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
@@ -244,6 +248,7 @@ pub fn plan(
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
BuildSystem::Dotnet => dotnet_steps(&dir, kind)?,
};
Ok(BuildPlan { system, title, steps })
}
@@ -451,6 +456,33 @@ fn has_build_script(dir: &Path) -> bool {
.is_some_and(|v| v.pointer("/scripts/build").is_some_and(serde_json::Value::is_string))
}
/// The solution (`.sln` / `.slnx`) or project (`.csproj` / `.fsproj` / `.vbproj`) file `dir`
/// builds: a solution first, then a project; the first by name when there are several.
fn dotnet_target(dir: &Path) -> Option<PathBuf> {
let mut files: Vec<PathBuf> =
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
files.sort();
let ext = |p: &Path| p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let solution = files.iter().find(|p| matches!(ext(p).as_str(), "sln" | "slnx"));
solution.or_else(|| files.iter().find(|p| matches!(ext(p).as_str(), "csproj" | "fsproj" | "vbproj"))).cloned()
}
/// `dotnet build` of the directory's solution or project; Rebuild skips incremental builds,
/// Build Artifacts publishes. The summary that repeats every message is left out.
fn dotnet_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
let target = dotnet_target(dir).ok_or_else(|| IdeError::not_found("no .NET solution or project found"))?;
let target = target.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
let verb = if kind == BuildKind::Artifacts { "publish" } else { "build" };
let mut step = BuildStep::new("dotnet", &[verb, &target, "-nologo", "-clp:NoSummary"], dir, Format::Msbuild);
if kind == BuildKind::Rebuild {
step.args.push("--no-incremental".into());
}
// The classic console logger prints the canonical `file(line,col): error` lines; the
// terminal logger (.NET 8+) only appears on a terminal, but rules it out for older SDKs too.
step.env.insert("MSBUILDTERMINALLOGGER".into(), "off".into());
Ok(vec![step])
}
fn ts_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let script = has_build_script(dir);
if script && (kind == BuildKind::Artifacts || !dir.join("tsconfig.json").is_file()) {
@@ -1141,6 +1173,24 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
// .NET: the solution, else the project; a module build is the file's project. An ASP.NET
// project's package.json (its front end) doesn't make it a TypeScript build.
let net = tmp.path().join("net");
write(&net, "App.sln", "");
write(&net, "src/Api/Api.csproj", "<Project/>");
write(&net, "src/Api/package.json", "{}");
write(&net, "src/Api/Program.cs", "");
let p = plan(&net, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Dotnet);
assert_eq!(p.steps[0].args, ["build", "App.sln", "-nologo", "-clp:NoSummary"]);
assert_eq!(p.steps[0].env["MSBUILDTERMINALLOGGER"], "off");
let p = plan(&net, &BuildModel::default(), BuildKind::Rebuild, Some(&net.join("src/Api/Program.cs")), None)
.unwrap();
assert_eq!(p.steps[0].args, ["build", "Api.csproj", "-nologo", "-clp:NoSummary", "--no-incremental"]);
assert_eq!(p.steps[0].cwd, net.join("src/Api"));
let p = plan(&net, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
assert_eq!(p.steps[0].args[0], "publish");
let cm = tmp.path().join("cm");
write(&cm, "CMakeLists.txt", "project(app)");
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
+61
Переглянути файл
@@ -25,6 +25,8 @@ pub(crate) enum Format {
Go,
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
Gcc,
/// MSBuild (`dotnet build`): `C:\p\A.cs(12,5): error CS1002: msg [C:\p\App.csproj]`.
Msbuild,
}
/// What a printed column counts.
@@ -268,6 +270,43 @@ fn tsc_line(line: &str) -> Option<RawMessage> {
Some(m)
}
/// MSBuild's canonical form: `path(12,5): error CS1002: msg [project]`, `path(12,5,12,9): …`,
/// or `path : error NU1101: msg [project]` for a project-level message (no position).
/// The path may contain spaces (`C:\Users\Jane Doe\…`); "error" / "warning" are never localized.
fn msbuild_line(line: &str) -> Option<RawMessage> {
let line = line.trim();
// The project the message came from, appended by the console logger.
let line = match line.rfind(" [") {
Some(i) if line.ends_with(']') => &line[..i],
_ => line,
};
let (head, severity, rest) =
[(" error ", Severity::Error), (" warning ", Severity::Warning)].into_iter().find_map(|(word, severity)| {
let i = line.find(&format!(":{word}"))?;
Some((&line[..i], severity, &line[i + word.len() + 1..]))
})?;
let (code, msg) = rest.split_once(':')?;
let code = code.trim();
if code.is_empty() || code.contains(' ') {
return None;
}
let head = head.trim_end();
let (path, pos) = match head.strip_suffix(')').and_then(|h| h.rsplit_once('(')) {
Some((path, pos)) => (path, pos.split(',').map(num).collect::<Vec<_>>()),
None => (head, Vec::new()),
};
let at = |i: usize| pos.get(i).copied().flatten();
// `MSBUILD : error MSB1009` and the like name no file.
let file = path.rsplit(['/', '\\']).next().is_some_and(|n| n.contains('.'));
let mut m = RawMessage::new(severity, msg.trim(), ColUnit::Utf16);
if file {
m.path = Some(path.trim().to_owned());
(m.line, m.col, m.end_line, m.end_col) = (at(0), at(1), at(2), at(3));
}
m.code = Some(code.to_owned());
Some(m)
}
/// go: `./a.go:12:5: msg` (errors only; `go vet` style `a.go:12: msg` too).
fn go_line(line: &str) -> Option<RawMessage> {
let (path, ln, col, rest) = path_line_col(line)?;
@@ -404,6 +443,7 @@ impl OutputParser {
Format::Tsc => tsc_line(&line),
Format::Go => go_line(&line),
Format::Gcc => gcc_line(&line),
Format::Msbuild => msbuild_line(&line),
Format::Cargo => None,
};
if let Some(msg) = parsed {
@@ -551,6 +591,27 @@ mod tests {
assert!(plain.messages.is_empty() && plain.console.is_none());
}
#[test]
fn msbuild_canonical_messages() {
let out = parse(
Format::Msbuild,
" Determining projects to restore...\n\
C:\\Users\\Jane Doe\\App\\Program.cs(12,5): error CS1002: ; expected [C:\\Users\\Jane Doe\\App\\App.csproj]\n\
/p/App/Models/User.cs(3,8,3,12): warning CS0168: The variable 'e' is declared but never used [/p/App/App.csproj]\n\
/p/App/App.csproj : error NU1101: Unable to find package Foo. [/p/App/App.csproj]\n\
MSBUILD : error MSB1009: Project file does not exist.\n\
Build FAILED.\n",
);
assert_eq!(out.len(), 4, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:\\Users\\Jane Doe\\App\\Program.cs", 12, Some(5)));
assert_eq!((out[0].code.as_deref(), out[0].text.as_str()), (Some("CS1002"), "; expected"));
assert_eq!(pos(&out[1]), (Severity::Warning, "/p/App/Models/User.cs", 3, Some(8)));
assert_eq!((out[1].end_line, out[1].end_col), (Some(3), Some(12)));
assert_eq!((out[2].path.as_deref(), out[2].line), (Some("/p/App/App.csproj"), None));
assert_eq!(out[2].text, "Unable to find package Foo.");
assert_eq!((out[3].path.as_deref(), out[3].code.as_deref()), (None, Some("MSB1009")));
}
#[test]
fn tsc_plain_and_pretty() {
let out = parse(
+4
Переглянути файл
@@ -118,6 +118,10 @@ pub enum IdeEvent {
LanguageServer {
server: ServerId,
name: String,
/// What the user sees as the server when another program serves in its place.
#[serde(default)]
#[specta(optional)]
label: Option<String>,
status: ServerStatus,
message: Option<String>,
},
+9 -1
Переглянути файл
@@ -1,8 +1,10 @@
//! JSON with comments (`devcontainer.json`, `deno.jsonc`, `tsconfig.json`): turned
//! into plain JSON for `serde_json`.
/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON.
/// Strips `//` and `/* */` comments, trailing commas and a leading byte order mark (Visual
/// Studio writes one into `launchSettings.json`) so JSONC parses as JSON.
pub fn strip(src: &str) -> String {
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let mut in_str = false;
@@ -87,4 +89,10 @@ mod tests {
assert_eq!(v["url"], "https://x.dev//y /* no */");
assert_eq!(v["q"], "say \"hi\",");
}
#[test]
fn drops_a_byte_order_mark() {
let v: serde_json::Value = serde_json::from_str(&strip("\u{feff}{ \"a\": 1 }")).unwrap();
assert_eq!(v["a"], 1);
}
}
+23 -1
Переглянути файл
@@ -1092,7 +1092,8 @@ impl Default for ExternalFormatterSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PythonSettings {
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`).
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`);
/// `target:<run-target-id>` = the Python of a Docker or Compose run target.
pub interpreter: String,
/// Activate the interpreter's virtualenv in new terminal sessions.
pub activate_virtualenv: bool,
@@ -1121,6 +1122,17 @@ impl Default for PythonSettings {
}
}
impl PythonSettings {
/// What `interpreter` starts with when the project interpreter is a run target's.
pub const TARGET_PREFIX: &str = "target:";
/// The run target whose Python is the project interpreter (`target:<id>`), if one is.
pub fn run_target(&self) -> Option<&str> {
let id = self.interpreter.trim().strip_prefix(Self::TARGET_PREFIX)?.trim();
(!id.is_empty()).then_some(id)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PhpSettings {
@@ -1542,6 +1554,16 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn python_interpreter_may_name_a_run_target() {
let python = |interpreter: &str| PythonSettings { interpreter: interpreter.into(), ..Default::default() };
assert_eq!(python("target:web").run_target(), Some("web"));
assert_eq!(python(" target: web ").run_target(), Some("web"));
for local in ["", "target:", "/usr/bin/python3", ".venv/bin/python", "targets/python"] {
assert_eq!(python(local).run_target(), None, "{local}");
}
}
#[test]
fn removed_one_themes_read_as_islands_themes() {
let settings: Settings = serde_json::from_str(
+2
Переглянути файл
@@ -7,6 +7,8 @@ license.workspace = true
[dependencies]
futures.workspace = true
tokio-tungstenite.workspace = true
base64.workspace = true
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
+528
Переглянути файл
@@ -0,0 +1,528 @@
//! Replacing the responses a debugged page receives. js-debug hands out a WebSocket that
//! speaks the browser's own protocol (`requestCDPProxy`); over it the browser is told to hold
//! the requests whose URL matches a rule (`Fetch.enable`) and each held request is answered
//! here, with the rule's response or by letting it through.
//!
//! A held request stays held until it is answered, so interception is always switched off
//! (`Fetch.disable`) before the connection is given up: a page whose requests nobody answers hangs.
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT, js_debug};
use base64::Engine;
use futures::{SinkExt, StreamExt};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::HashMap;
use tokio::sync::{oneshot, watch};
use tokio_tungstenite::tungstenite::{Message, http::StatusCode};
/// A response given in place of the server's to every request whose URL contains `url_contains`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsResponseRule {
pub url_contains: String,
pub status: u16,
pub content_type: String,
pub body: String,
}
/// The browser's URL pattern (`*` any text, `?` one character, `\` escapes) for "contains `part`".
pub(crate) fn url_pattern(part: &str) -> String {
let mut pattern = String::with_capacity(part.len() + 2);
pattern.push('*');
for c in part.chars() {
if matches!(c, '*' | '?' | '\\') {
pattern.push('\\');
}
pattern.push(c);
}
pattern.push('*');
pattern
}
pub(crate) fn rule_for<'a>(rules: &'a [JsResponseRule], url: &str) -> Option<&'a JsResponseRule> {
rules.iter().find(|rule| url.contains(&rule.url_contains))
}
/// What to tell the browser about a request it holds: the first matching rule's response, or
/// to send the request on. Every held request gets one of the two.
pub(crate) fn answer(held: &Value, rules: &[JsResponseRule]) -> (&'static str, Value) {
let request_id = held.get("requestId").and_then(Value::as_str).unwrap_or_default();
let url = held.pointer("/request/url").and_then(Value::as_str).unwrap_or_default();
let Some(rule) = rule_for(rules, url) else {
return ("Fetch.continueRequest", json!({ "requestId": request_id }));
};
let header = |name: &str, value: &str| json!({ "name": name, "value": value });
// A page on another origin only reads a response that names its origin. The server would
// say so; the response given in its place has to.
let mut cross_origin = Vec::new();
if let Some(origin) = request_header(held, "Origin") {
cross_origin.push(header("Access-Control-Allow-Origin", origin));
cross_origin.push(header("Access-Control-Allow-Credentials", "true"));
}
let method = held.pointer("/request/method").and_then(Value::as_str).unwrap_or_default();
// The browser's question before such a request: allowed here, the server may not exist.
if method == "OPTIONS"
&& let Some(asked) = request_header(held, "Access-Control-Request-Method")
{
cross_origin.push(header("Access-Control-Allow-Methods", asked));
if let Some(asked) = request_header(held, "Access-Control-Request-Headers") {
cross_origin.push(header("Access-Control-Allow-Headers", asked));
}
let allowed = json!({
"requestId": request_id,
"responseCode": 204,
"responsePhrase": phrase(204),
"responseHeaders": cross_origin,
});
return ("Fetch.fulfillRequest", allowed);
}
let mut headers = vec![header("Content-Type", &rule.content_type)];
headers.extend(cross_origin);
let replaced = json!({
"requestId": request_id,
"responseCode": rule.status,
"responsePhrase": phrase(rule.status),
"responseHeaders": headers,
"body": base64::engine::general_purpose::STANDARD.encode(rule.body.as_bytes()),
});
("Fetch.fulfillRequest", replaced)
}
/// A header of the held request; the browser reports names as the page wrote them.
fn request_header<'a>(held: &'a Value, name: &str) -> Option<&'a str> {
held.pointer("/request/headers")?.as_object()?.iter().find(|(key, _)| key.eq_ignore_ascii_case(name))?.1.as_str()
}
/// The browser refuses a response without a phrase when it has none of its own for the status.
fn phrase(status: u16) -> &'static str {
StatusCode::from_u16(status).ok().and_then(|status| status.canonical_reason()).unwrap_or("Unknown")
}
fn interception(rules: &[JsResponseRule]) -> Value {
let patterns: Vec<Value> = rules
.iter()
.map(|rule| json!({ "urlPattern": url_pattern(&rule.url_contains), "requestStage": "Request" }))
.collect();
json!({ "patterns": patterns })
}
/// Applies `rules` to the page behind js-debug's CDP proxy at `ws_url` until their sender is
/// dropped (the session is gone) or the proxy closes the connection. The browser holds
/// requests only while there are rules. `ready` hears whether the proxy reports held
/// requests at all; nothing is held before it has agreed to.
pub(crate) async fn serve(
ws_url: String,
mut rules: watch::Receiver<Vec<JsResponseRule>>,
ready: oneshot::Sender<Result<(), String>>,
) {
let socket = match tokio_tungstenite::connect_async(ws_url.as_str()).await {
Ok((socket, _)) => socket,
Err(e) => {
let _ = ready.send(Err(e.to_string()));
return;
}
};
let (mut write, mut read) = socket.split();
let mut last_id = 0u64;
let mut call = |method: &str, params: Value| {
last_id += 1;
(last_id, Message::text(json!({ "id": last_id, "method": method, "params": params }).to_string()))
};
let (subscription, subscribe) = call("JsDebug.subscribe", json!({ "events": ["Fetch.requestPaused"] }));
let subscribed = tokio::time::timeout(REQUEST_TIMEOUT, async {
write.send(subscribe).await.map_err(|e| e.to_string())?;
loop {
let reply = read.next().await.ok_or("the connection closed")?.map_err(|e| e.to_string())?;
let Some(reply) = reply.to_text().ok().and_then(|text| serde_json::from_str::<Value>(text).ok()) else {
continue;
};
if reply.get("id").and_then(Value::as_u64) != Some(subscription) {
continue;
}
break match reply.get("error") {
Some(error) => Err(error.get("message").and_then(Value::as_str).unwrap_or("refused").to_owned()),
None => Ok(()),
};
}
})
.await
.unwrap_or_else(|_| Err("no answer".to_owned()));
if let Err(why) = subscribed {
let _ = ready.send(Err(why));
let _ = write.close().await;
return;
}
let _ = ready.send(Ok(()));
let mut current = rules.borrow_and_update().clone();
let mut holding = !current.is_empty();
if holding && write.send(call("Fetch.enable", interception(&current)).1).await.is_err() {
return;
}
// The request each sent replacement is for, by command id, until the browser has taken it.
let mut replacing: HashMap<u64, String> = HashMap::new();
loop {
tokio::select! {
changed = rules.changed() => {
if changed.is_err() {
break;
}
current = rules.borrow_and_update().clone();
let (_, command) = match (current.is_empty(), holding) {
(true, false) => continue,
(true, true) => call("Fetch.disable", json!({})),
(false, _) => call("Fetch.enable", interception(&current)),
};
holding = !current.is_empty();
if write.send(command).await.is_err() {
return;
}
}
message = read.next() => {
// The proxy closed (the page or the session ended): nothing is held any more.
let Some(Ok(message)) = message else { return };
let Ok(text) = message.to_text() else { continue };
let Ok(message) = serde_json::from_str::<Value>(text) else { continue };
if let Some(id) = message.get("id").and_then(Value::as_u64) {
// The browser did not take the replacement: let the request through, or
// it stays held and the page waits for it.
if let Some(request_id) = replacing.remove(&id)
&& message.get("error").is_some()
{
let (_, go_on) = call("Fetch.continueRequest", json!({ "requestId": request_id }));
if write.send(go_on).await.is_err() {
return;
}
}
continue;
}
if message.get("method").and_then(Value::as_str) != Some("Fetch.requestPaused") {
continue;
}
let Some(held) = message.get("params") else { continue };
let Some(request_id) = held.get("requestId").and_then(Value::as_str) else { continue };
let (method, params) = answer(held, &current);
let (id, command) = call(method, params);
if method == "Fetch.fulfillRequest" {
replacing.insert(id, request_id.to_owned());
}
if write.send(command).await.is_err() {
return;
}
}
}
}
if holding {
let _ = write.send(call("Fetch.disable", json!({})).1).await;
}
let _ = write.close().await;
}
impl DebugManager {
/// Makes `rules` the page's response rules; none switches the replacing off. The first
/// rules of a session open the connection to js-debug's CDP proxy, later ones go over it.
/// An error means the rules are not applied.
pub async fn js_set_response_overrides(&self, s: DebugSessionId, rules: Vec<JsResponseRule>) -> IdeResult<()> {
let session = self.session(s)?;
if !js_debug::is_page(&session) {
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
}
// Held to the end: two callers at once would each open a connection.
let mut applied = session.js_overrides.lock().await;
if applied.as_ref().is_some_and(|sender| sender.send(rules.clone()).is_ok()) {
return Ok(());
}
// No connection yet, or the proxy closed it.
applied.take();
if rules.is_empty() {
return Ok(());
}
let refused =
|why: String| IdeError::unavailable(format!("the JavaScript debugger has no browser connection: {why}"));
if !js_debug::launched(&session).await {
return Err(refused("the page's debug session did not finish starting".into()));
}
let proxy = session
.conn
.request_timeout("requestCDPProxy", json!({}), REQUEST_TIMEOUT)
.await
.map_err(|e| refused(e.to_string()))?;
let (Some(host), Some(port)) =
(proxy.get("host").and_then(Value::as_str), proxy.get("port").and_then(Value::as_u64))
else {
return Err(refused("no address".into()));
};
let path = proxy.get("path").and_then(Value::as_str).unwrap_or_default();
let (sender, receiver) = watch::channel(rules);
let (ready, is_ready) = oneshot::channel();
tokio::spawn(serve(format!("ws://{host}:{port}{path}"), receiver, ready));
match is_ready.await {
Ok(Ok(())) => {
*applied = Some(sender);
Ok(())
}
Ok(Err(why)) => Err(refused(why)),
Err(_) => Err(refused("no answer".into())),
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use futures::{SinkExt, StreamExt};
use serde_json::json;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
pub(crate) fn rule(url_contains: &str, status: u16, body: &str) -> JsResponseRule {
JsResponseRule {
url_contains: url_contains.into(),
status,
content_type: "application/json".into(),
body: body.into(),
}
}
#[test]
fn a_part_of_a_url_becomes_a_pattern_that_matches_it_anywhere() {
assert_eq!(url_pattern("api/items"), "*api/items*");
// The pattern's own wildcards and escape character are taken literally.
assert_eq!(url_pattern(r"a*b?c\d"), r"*a\*b\?c\\d*");
}
#[test]
fn the_first_rule_whose_text_is_in_the_url_answers() {
let rules = [rule("api/items", 201, "{\"a\":\"é\"}"), rule("api", 500, "later")];
assert_eq!(rule_for(&rules, "http://h/api/items?x=1").map(|r| r.status), Some(201));
assert_eq!(rule_for(&rules, "http://h/api/users").map(|r| r.status), Some(500));
assert!(rule_for(&rules, "http://h/logo.png").is_none());
let (command, params) = answer(&held("7", "http://h/api/items")["params"], &rules);
assert_eq!(command, "Fetch.fulfillRequest");
assert_eq!(
params,
json!({
"requestId": "7",
"responseCode": 201,
"responsePhrase": "Created",
"responseHeaders": [{ "name": "Content-Type", "value": "application/json" }],
// base64 of the UTF-8 text `{"a":"é"}`
"body": "eyJhIjoiw6kifQ==",
})
);
// A request the browser held that no rule is for goes on to the network.
assert_eq!(
answer(&held("8", "http://h/logo.png")["params"], &rules),
("Fetch.continueRequest", json!({ "requestId": "8" }))
);
}
#[test]
fn a_status_the_browser_has_no_phrase_for_is_given_one() {
// The browser refuses a response whose status it cannot name.
let (_, params) = answer(&held("1", "http://h/api")["params"], &[rule("api", 419, "")]);
assert_eq!((&params["responseCode"], &params["responsePhrase"]), (&json!(419), &json!("Unknown")));
}
fn header<'a>(params: &'a Value, name: &str) -> Option<&'a str> {
params["responseHeaders"].as_array()?.iter().find(|h| h["name"] == name)?["value"].as_str()
}
#[test]
fn a_replaced_response_passes_the_pages_cross_origin_check() {
let rules = [rule("api/items", 200, "[]")];
let from_page = |method: &str, headers: Value| json!({ "requestId": "3", "request": { "url": "http://localhost:8080/api/items", "method": method, "headers": headers } });
// A page on another origin: the response names that origin, as the real server would.
let (_, params) = answer(&from_page("GET", json!({ "origin": "http://localhost:5173" })), &rules);
assert_eq!(header(&params, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
assert_eq!(header(&params, "Access-Control-Allow-Credentials"), Some("true"));
assert_eq!(params["body"], "W10=");
// The browser's question before such a request is answered too: the server may not exist.
let preflight = from_page(
"OPTIONS",
json!({
"Origin": "http://localhost:5173",
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "content-type,x-token",
}),
);
let (command, params) = answer(&preflight, &rules);
assert_eq!((command, &params["responseCode"]), ("Fetch.fulfillRequest", &json!(204)));
assert_eq!(header(&params, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
assert_eq!(header(&params, "Access-Control-Allow-Methods"), Some("PUT"));
assert_eq!(header(&params, "Access-Control-Allow-Headers"), Some("content-type,x-token"));
assert_eq!(params.get("body"), None);
// Same origin: nothing but the rule's own header.
let (_, params) = answer(&from_page("GET", json!({})), &rules);
assert_eq!(params["responseHeaders"], json!([{ "name": "Content-Type", "value": "application/json" }]));
}
/// A stand-in for js-debug's CDP proxy: what it receives, and a way to send it events.
pub(crate) async fn proxy() -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
proxy_refusing(|_| false).await
}
/// A proxy that answers the commands `refused` picks with an error, like the real one does
/// for what it or the browser does not accept.
pub(crate) async fn proxy_refusing(
refused: fn(&Value) -> bool,
) -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
let (received_tx, received) = mpsc::unbounded_channel();
let (events, mut events_rx) = mpsc::unbounded_channel::<Value>();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (mut write, mut read) = tokio_tungstenite::accept_async(stream).await.unwrap().split();
loop {
tokio::select! {
message = read.next() => {
let Some(Ok(message)) = message else { break };
let Ok(text) = message.to_text() else { continue };
let Ok(request) = serde_json::from_str::<Value>(text) else { continue };
let reply = if refused(&request) {
json!({ "id": request["id"], "error": { "code": -32602, "message": "refused" } })
} else {
json!({ "id": request["id"], "result": {} })
};
let _ = write.send(Message::text(reply.to_string())).await;
let _ = received_tx.send(request);
}
Some(event) = events_rx.recv() => {
let _ = write.send(Message::text(event.to_string())).await;
}
}
}
});
(url, received, events)
}
pub(crate) async fn next(received: &mut mpsc::UnboundedReceiver<Value>) -> (String, Value) {
let request = tokio::time::timeout(std::time::Duration::from_secs(5), received.recv())
.await
.expect("the proxy got a request in time")
.expect("the connection is open");
(request["method"].as_str().unwrap().to_owned(), request["params"].clone())
}
/// The browser's event for a request it holds.
fn held(request_id: &str, url: &str) -> Value {
json!({ "method": "Fetch.requestPaused", "params": { "requestId": request_id, "request": { "url": url } } })
}
type Serving = (tokio::task::JoinHandle<()>, oneshot::Receiver<Result<(), String>>);
fn serving(url: String, rules: watch::Receiver<Vec<JsResponseRule>>) -> Serving {
let (ready, is_ready) = oneshot::channel();
(tokio::spawn(serve(url, rules, ready)), is_ready)
}
async fn ended(task: tokio::task::JoinHandle<()>) {
tokio::time::timeout(std::time::Duration::from_secs(5), task).await.unwrap().unwrap();
}
#[tokio::test]
async fn held_requests_are_answered_and_interception_ends_before_the_connection() {
let (url, mut received, events) = proxy().await;
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api/items", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert_eq!(ready.await.unwrap(), Ok(()));
assert_eq!(
next(&mut received).await,
("JsDebug.subscribe".into(), json!({ "events": ["Fetch.requestPaused"] }))
);
assert_eq!(
next(&mut received).await,
(
"Fetch.enable".into(),
json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] })
)
);
events.send(held("1", "http://h/api/items")).unwrap();
let (command, params) = next(&mut received).await;
assert_eq!(
(command.as_str(), &params["requestId"], &params["responseCode"]),
("Fetch.fulfillRequest", &json!("1"), &json!(200))
);
events.send(held("2", "http://h/other")).unwrap();
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "2" })));
// New rules replace the browser's patterns.
rules.send(vec![rule("api/users", 404, "{}")]).unwrap();
assert_eq!(
next(&mut received).await,
(
"Fetch.enable".into(),
json!({ "patterns": [{ "urlPattern": "*api/users*", "requestStage": "Request" }] })
)
);
// No rules left: the browser stops holding requests. The connection stays for the
// rules that come next, and goes with the session.
rules.send(Vec::new()).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
rules.send(vec![rule("api/items", 200, "[]")]).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.enable");
rules.send(Vec::new()).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
drop(rules);
ended(serving).await;
// Already off: not switched off a second time.
assert!(received.try_recv().is_err());
}
#[tokio::test]
async fn a_session_that_goes_away_switches_interception_off() {
let (url, mut received, _events) = proxy().await;
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, _ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(next(&mut received).await.0, "Fetch.enable");
drop(rules);
assert_eq!(next(&mut received).await.0, "Fetch.disable");
ended(serving).await;
}
#[tokio::test]
async fn a_response_the_browser_refuses_lets_the_request_through() {
let (url, mut received, events) = proxy_refusing(|request| request["method"] == "Fetch.fulfillRequest").await;
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (_serving, _ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(next(&mut received).await.0, "Fetch.enable");
events.send(held("9", "http://h/api")).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.fulfillRequest");
// Unanswered, the request would hang the page.
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "9" })));
}
#[tokio::test]
async fn a_proxy_that_does_not_report_held_requests_is_not_asked_to_hold_any() {
let (url, mut received, _events) = proxy_refusing(|request| request["method"] == "JsDebug.subscribe").await;
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(ready.await.unwrap(), Err("refused".to_owned()));
ended(serving).await;
assert!(received.try_recv().is_err());
}
#[tokio::test]
async fn no_connection_to_the_proxy_is_reported() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
drop(listener);
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert!(ready.await.unwrap().is_err());
ended(serving).await;
}
}
+41 -1
Переглянути файл
@@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
pub(crate) const ADAPTER: &str = "js-debug";
@@ -25,7 +27,7 @@ pub(crate) fn is_page(session: &Session) -> bool {
/// 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 {
pub(crate) 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(_)))
@@ -59,6 +61,44 @@ impl DebugManager {
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
Ok(())
}
/// Starts recording the page's CPU profile; [`Self::js_stop_profile`] ends it.
pub async fn js_start_profile(&self, s: DebugSessionId) -> IdeResult<()> {
self.js_page_request(s, "startProfile", json!({ "type": "cpu" })).await?;
Ok(())
}
/// Ends the recording and returns a new directory holding it as `page.cpuprofile`
/// (what `ide-profiler` reads a directory of).
pub async fn js_stop_profile(&self, s: DebugSessionId) -> IdeResult<PathBuf> {
let session = self.session(s)?;
// js-debug names the file it records into when the recording starts.
let Some(recorded) = session.js_profile.lock().take() else {
return Err(IdeError::invalid("no CPU profile is being recorded"));
};
self.js_page_request(s, "stopProfile", json!({})).await?;
static HANDED_OVER: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"openidea-js-profile-{}-{}-{}",
std::process::id(),
s.0,
HANDED_OVER.fetch_add(1, Ordering::Relaxed)
));
let copied = tokio::task::spawn_blocking({
let dir = dir.clone();
move || {
std::fs::create_dir_all(&dir)?;
std::fs::copy(&recorded, dir.join("page.cpuprofile"))?;
// The adapter's own copy is in the system's temporary directory: no use keeping two.
let _ = std::fs::remove_file(&recorded);
Ok::<_, std::io::Error>(())
}
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
copied.map_err(|e| IdeError::unavailable(format!("the recorded CPU profile cannot be read: {e}")))?;
Ok(dir)
}
}
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
+2
Переглянути файл
@@ -13,6 +13,7 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod js_cdp;
mod js_debug;
mod jvm_memory;
pub mod kotlin;
@@ -24,6 +25,7 @@ use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use js_cdp::JsResponseRule;
pub use js_debug::JsResponseBody;
pub use jvm_memory::*;
use parking_lot::Mutex;
+11
Переглянути файл
@@ -94,6 +94,11 @@ pub(crate) struct Session {
/// 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>,
/// The response rules of a page once a connection applies them (`js_cdp::serve`).
/// Dropped with the session, which switches the browser's interception off.
pub js_overrides: tokio::sync::Mutex<Option<tokio::sync::watch::Sender<Vec<crate::JsResponseRule>>>>,
/// The file js-debug records the page's CPU profile into, while one is being recorded.
pub js_profile: parking_lot::Mutex<Option<std::path::PathBuf>>,
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<()>,
@@ -255,6 +260,8 @@ impl Session {
progress: Mutex::new(HashMap::new()),
jdwp_link: parking_lot::Mutex::new(None),
launch_type: std::sync::OnceLock::new(),
js_overrides: tokio::sync::Mutex::new(None),
js_profile: parking_lot::Mutex::new(None),
jvm_inspector: parking_lot::Mutex::new(None),
inspect_lock: tokio::sync::Mutex::default(),
ended: ide_core::CancellationToken::new(),
@@ -842,6 +849,10 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
s.emit(DebugEvent::JsNetwork { event });
}
}
// js-debug, after `startProfile`: where it records the profile.
"profileStarted" => {
*s.js_profile.lock() = str_("file").map(std::path::PathBuf::from);
}
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+136
Переглянути файл
@@ -1104,6 +1104,142 @@ async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
}
/// Where the fake adapter says its CDP proxy listens (`requestCDPProxy`).
static CDP_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
fn proxy_adapter(msg: &Value) -> Option<CustomReply> {
let port = CDP_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
(msg["command"] == "requestCDPProxy")
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
}
#[tokio::test]
async fn response_overrides_reach_the_page_through_the_adapters_proxy() {
use crate::js_cdp::tests::{next, proxy, rule};
let (url, mut received, _events) = proxy().await;
let port: u16 = url.trim_start_matches("ws://127.0.0.1:").trim_end_matches("/session").parse().unwrap();
CDP_PROXY_PORT.store(port, std::sync::atomic::Ordering::Relaxed);
let h = harness(Script {
start_debugging: true,
custom: Some(proxy_adapter),
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;
// Rules that are there as the page starts wait until its session takes requests.
tokio::time::sleep(Duration::from_millis(50)).await;
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "the proxy was asked for before the page took requests");
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(
next(&mut received).await,
("Fetch.enable".into(), json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] }))
);
// Changed rules go over the connection that is open: the proxy is asked for once.
h.manager.js_set_response_overrides(page, vec![rule("api/users", 200, "[]")]).await.unwrap();
assert_eq!(next(&mut received).await.1["patterns"][0]["urlPattern"], "*api/users*");
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
// A rule switched back on.
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.enable");
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
let not_a_page = h.manager.js_set_response_overrides(browser, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
}
#[tokio::test]
async fn response_overrides_are_unavailable_without_the_adapters_proxy() {
use crate::js_cdp::tests::rule;
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
let refused = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
// Nothing to switch off is not an error.
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
}
/// A port nothing listens on, which the fake adapter gives as its CDP proxy.
static DEAD_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
fn dead_proxy_adapter(msg: &Value) -> Option<CustomReply> {
let port = DEAD_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
(msg["command"] == "requestCDPProxy")
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
}
#[tokio::test]
async fn response_overrides_that_cannot_reach_the_browser_are_an_error() {
use crate::js_cdp::tests::rule;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
DEAD_PROXY_PORT.store(listener.local_addr().unwrap().port(), std::sync::atomic::Ordering::Relaxed);
drop(listener);
let h = harness(Script { start_debugging: true, custom: Some(dead_proxy_adapter), ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
let unreachable = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(unreachable.kind, ide_core::error::ErrorKind::Unavailable);
// The next rules try again instead of going to a connection that never was.
let again = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(again.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(requests(&h, "requestCDPProxy").len(), 2);
}
/// The file the fake adapter says it records a CPU profile into (`profileStarted`).
static PROFILE_FILE: parking_lot::Mutex<String> = parking_lot::Mutex::new(String::new());
fn profiling_adapter(msg: &Value) -> Option<CustomReply> {
match msg["command"].as_str()? {
"startProfile" => {
Some((json!({}), vec![("profileStarted", json!({ "file": *PROFILE_FILE.lock(), "type": "cpu" }))]))
}
"stopProfile" => Some((json!({}), vec![("profilerStateUpdate", json!({ "label": "", "running": false }))])),
_ => None,
}
}
#[tokio::test]
async fn a_pages_cpu_profile_is_handed_over_as_a_cpuprofile_file() {
let recorded = std::env::temp_dir().join(format!("openidea-dap-profile-{}", std::process::id()));
std::fs::write(&recorded, r#"{"nodes":[],"samples":[]}"#).unwrap();
*PROFILE_FILE.lock() = recorded.to_string_lossy().into_owned();
let h = harness(Script { start_debugging: true, custom: Some(profiling_adapter), ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Nothing is being recorded yet.
let idle = h.manager.js_stop_profile(page).await.unwrap_err();
assert_eq!(idle.kind, ide_core::error::ErrorKind::InvalidInput);
h.manager.js_start_profile(page).await.unwrap();
assert_eq!(requests(&h, "startProfile"), [json!({ "type": "cpu" })]);
let dir = h.manager.js_stop_profile(page).await.unwrap();
assert_eq!(requests(&h, "stopProfile").len(), 1);
let files: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(files, ["page.cpuprofile"]);
assert_eq!(std::fs::read_to_string(dir.join("page.cpuprofile")).unwrap(), r#"{"nodes":[],"samples":[]}"#);
// One recording is handed over once.
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::InvalidInput);
// The adapter's file isn't there when it is asked for (the hand-over above took the first one).
assert!(!recorded.exists());
h.manager.js_start_profile(page).await.unwrap();
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(h.manager.js_start_profile(browser).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
let _ = std::fs::remove_dir_all(dir);
}
#[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]");
+18
Переглянути файл
@@ -542,6 +542,24 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
/// Runs the docker CLI with `args` in `dir` and returns its standard output; a non-zero
/// exit is an error with docker's message.
pub fn cli(
ep: &DockerEndpoint,
dir: Option<&Path>,
args: &[String],
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> IdeResult<String> {
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let mut cmd = command(ep, &args)?;
if let Some(dir) = dir {
cmd.current_dir(dir);
}
let out = process::run(cmd, timeout, cancel)?;
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
#[cfg(test)]
mod local_tests {
use super::*;
+32
Переглянути файл
@@ -382,6 +382,15 @@ pub async fn debug_js_custom_breakpoints(
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
}
pub async fn debug_js_response_overrides(
host: &Host,
_window: &str,
session: DebugSessionId,
rules: Vec<ide_dap::JsResponseRule>,
) -> IdeResult<()> {
host.debugger.js_set_response_overrides(session, rules).await
}
pub async fn debug_js_response_body(
host: &Host,
_window: &str,
@@ -391,6 +400,18 @@ pub async fn debug_js_response_body(
host.debugger.js_response_body(session, request_id).await
}
pub async fn debug_js_start_profile(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.js_start_profile(session).await
}
pub async fn debug_js_stop_profile(
host: &Host,
_window: &str,
session: DebugSessionId,
) -> IdeResult<ide_core::IpcPath> {
host.debugger.js_stop_profile(session).await.map(Into::into)
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
@@ -607,12 +628,23 @@ 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_overrides", |c| async move {
debug_js_response_overrides(&c.host, &c.window, c.arg("session")?, c.arg("rules")?).await
});
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_js_start_profile",
|c| async move { debug_js_start_profile(&c.host, &c.window, c.arg("session")?).await },
);
d.add(
"debug_js_stop_profile",
|c| async move { debug_js_stop_profile(&c.host, &c.window, c.arg("session")?).await },
);
d.add("debug_set_function_breakpoints", |c| async move {
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
+10
Переглянути файл
@@ -159,6 +159,15 @@ pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<
.await
}
/// Python class facts the inheritance inspections read: base names as written, a method's
/// parameters, decorator semantics and its body shape. Tree-sitter answers; no LSP.
pub async fn doc_python_facts(host: &Host, window: &str, doc: DocId) -> IdeResult<ide_language::PythonFacts> {
let project = host.project(window)?;
let syntax = host.syntax();
let snap = project.documents.text_snapshot(doc)?;
blocking(move || Ok(syntax.python_facts(&snap.language, &snap.text))).await
}
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
/// Large documents fall back to word / line / document ranges.
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
@@ -208,6 +217,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_python_facts", |c| async move { doc_python_facts(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_selection_ranges", |c| async move {
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
});
+12
Переглянути файл
@@ -1472,6 +1472,15 @@ pub async fn lsp_type_hierarchy(
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
}
pub async fn lsp_prepare_type_hierarchy(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
) -> IdeResult<Option<HierarchyItem>> {
host.project(window)?.lsp.prepare_type_hierarchy(doc, pos).await
}
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
Ok(host.project(window)?.lsp.servers())
}
@@ -1856,6 +1865,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_type_hierarchy", |c| async move {
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
});
d.add("lsp_prepare_type_hierarchy", |c| async move {
lsp_prepare_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
});
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
+6
Переглянути файл
@@ -239,6 +239,12 @@ pub fn set_settings(
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// A run target chosen as the Python interpreter (or changed) needs a snapshot of its libraries.
if ["languages", "languages.python", "languages.python.interpreter", "runTargets"].contains(&key) {
for project in affected() {
crate::ops::sdk::container_python::ensure(host, &project);
}
}
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
+1 -1
Переглянути файл
@@ -508,7 +508,7 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub(crate) mod container;
pub use commands::*;
#[cfg(test)]
+26 -15
Переглянути файл
@@ -33,23 +33,11 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
let root = root.to_path_buf();
let data_dir = host.data_dir.clone();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
require_local(&endpoint, &target)?;
let docker = ide_docker::docker_executable().ok_or_else(|| {
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
})?;
let container_root = match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), &root)?
}
"" => default_container_root(&root),
folder => folder.to_owned(),
};
let container_root = container_root(&endpoint, &root, &target)?;
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
@@ -75,6 +63,29 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
.await
}
/// The project can only be bind-mounted from a daemon on this machine.
pub(crate) fn require_local(endpoint: &DockerEndpoint, target: &RunTarget) -> IdeResult<()> {
if !ide_docker::endpoint_is_local(endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
Ok(())
}
/// Where the project is in the target's containers.
pub(crate) fn container_root(endpoint: &DockerEndpoint, root: &Path, target: &RunTarget) -> IdeResult<String> {
Ok(match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(endpoint, root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), root)?
}
"" => default_container_root(root),
folder => folder.to_owned(),
})
}
/// What a debug run mounts and publishes.
struct Debugging {
port: u16,
@@ -104,7 +115,7 @@ fn free_port() -> IdeResult<u16> {
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
pub(crate) fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
if id.is_empty() {
return Ok(DockerEndpoint::Default);
+12 -2
Переглянути файл
@@ -10,6 +10,9 @@ use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
pub(crate) mod container_python;
pub use container_python::{ContainerPython, sdk_container_python, sdk_container_python_refresh};
fn join_err(e: impl std::fmt::Display) -> IdeError {
IdeError::internal(e.to_string())
}
@@ -232,12 +235,17 @@ pub async fn sdk_project_libraries(
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let cancel = host.begin_project_request(window, request);
let container = container_python::library_group(host, &root, &settings);
let libraries = {
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
tokio::task::spawn_blocking(move || {
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
if let Some(group) = &container {
let _ = on_groups.send(vec![group.clone()]);
}
let groups = ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
let _ = on_groups.send(groups);
})
});
container.into_iter().chain(groups).collect::<Vec<_>>()
})
};
let dependencies = {
@@ -377,6 +385,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add("sdk_container_python", |c| async move { sdk_container_python(&c.host, &c.window) });
d.add("sdk_container_python_refresh", |c| async move { sdk_container_python_refresh(&c.host, &c.window).await });
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
d.add(
"sdk_php_language_level",
+298
Переглянути файл
@@ -0,0 +1,298 @@
//! A Docker or Compose run target's Python as the project interpreter: snapshots of the
//! container's library folders for the language server (see `ide_sdk::container_python`).
//!
//! A snapshot runs the target's Python once in a new container (the image's entrypoint
//! and the service's dependencies skipped), then copies its `sys.path` folders out of
//! the stopped container with `docker cp`. Nothing is installed in the container.
use crate::Host;
use crate::project::Project;
use ide_core::event::NotifyLevel;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::settings::Settings;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, TaskId};
use ide_sdk::container_python::{self as snapshots, Snapshot};
use ide_sdk::python::PythonPackage;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
/// Building or pulling the image may come first.
const PROBE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
const COPY_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const QUICK: Duration = Duration::from_secs(30);
/// The project interpreter when it is a run target's.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ContainerPython {
/// The run target's id.
pub target: String,
/// Its name; empty when no run target has the id.
pub name: String,
/// The Python version of the snapshot in use; `None`: no snapshot yet.
pub version: Option<String>,
/// The image the snapshot was copied from.
pub image: Option<String>,
/// The copied library folders on this machine.
pub folders: Vec<IpcPath>,
/// Packages of the snapshot (read-only).
pub packages: Vec<PythonPackage>,
/// A snapshot is being taken.
pub updating: bool,
}
/// Where snapshots are kept, and the Docker connections the targets refer to.
#[derive(Clone)]
struct Dirs {
data: PathBuf,
/// `<local data>/container-python/`: `<project>/<target>/<image>/` and `neutral/`.
cache: PathBuf,
}
impl Dirs {
fn of(host: &Host) -> Self {
Self::new(&host.data_dir, &host.local_data_dir)
}
fn new(data_dir: &Path, local_data_dir: &Path) -> Self {
Self { data: data_dir.to_path_buf(), cache: local_data_dir.join("container-python") }
}
fn target(&self, root: &Path, target: &str) -> PathBuf {
snapshots::target_dir(&self.cache.join(crate::project::project_key(root)), target)
}
}
/// Target folders with a snapshot being taken.
static UPDATING: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(Default::default);
fn updating(dir: &Path) -> bool {
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).contains(dir)
}
/// The snapshot in use for the project's interpreter, if it is a run target's.
fn current(dirs: &Dirs, root: &Path, settings: &Settings) -> Option<(PathBuf, Snapshot)> {
let target = settings.languages.python.run_target()?;
snapshots::current(&dirs.target(root, target))
}
/// Language server configuration: basedpyright / pyright see the container's libraries.
pub(crate) fn configure_server(
cfg: &mut ide_language::LanguageServerConfig,
local_data_dir: &Path,
root: &Path,
settings: &Settings,
) {
if settings.languages.python.run_target().is_none() {
return;
}
let current = current(&Dirs::new(local_data_dir, local_data_dir), root, settings);
snapshots::configure_server(cfg, current.as_ref().map(|(d, s)| (d.as_path(), s)));
}
/// External Libraries: the snapshot's folders, named after the run target.
pub(crate) fn library_group(host: &Host, root: &Path, settings: &Settings) -> Option<ide_sdk::libraries::LibraryGroup> {
let target = settings.languages.python.run_target()?;
let (dir, snapshot) = current(&Dirs::of(host), root, settings)?;
let name = settings.run_targets.iter().find(|t| t.id == target).map_or(target, |t| t.name.as_str());
snapshots::library_group(name, &dir, &snapshot)
}
/// The project interpreter when it is a run target's, with its snapshot.
pub fn sdk_container_python(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
host.require_trust(window)?;
let project = host.project(window)?;
Ok(describe(host, &project.root, &project.settings.effective()))
}
fn describe(host: &Host, root: &Path, settings: &Settings) -> Option<ContainerPython> {
let target = settings.languages.python.run_target()?;
let name = settings.run_targets.iter().find(|t| t.id == target).map(|t| t.name.clone()).unwrap_or_default();
let dir = Dirs::of(host).target(root, target);
let mut out = ContainerPython {
target: target.to_owned(),
name,
version: None,
image: None,
folders: Vec::new(),
packages: Vec::new(),
updating: updating(&dir),
};
if let Some((dir, snapshot)) = snapshots::current(&dir) {
out.folders = snapshots::local_folders(&dir, &snapshot).into_iter().map(Into::into).collect();
out.packages = snapshots::packages(&dir, &snapshot);
out.version = Some(snapshot.probe.version);
out.image = Some(snapshot.image);
}
Some(out)
}
/// Takes a new snapshot of the project interpreter's container (also of an image that
/// has one already), then the language servers pick it up.
pub async fn sdk_container_python_refresh(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
host.require_trust(window)?;
let project = host.project(window)?;
update(Dirs::of(host), &project, true).await?;
Ok(describe(host, &project.root, &project.settings.effective()))
}
/// Takes a snapshot when the project interpreter is a run target's and the current image
/// has none yet (a new choice, a rebuilt image), in the background. Errors are notified.
pub(crate) fn ensure(host: &Host, project: &Arc<Project>) {
if !project.is_trusted() || project.settings.effective().languages.python.run_target().is_none() {
return;
}
let (dirs, project) = (Dirs::of(host), project.clone());
host.runtime.spawn(async move {
if let Err(e) = update(dirs, &project, false).await
&& e.kind != ide_core::error::ErrorKind::Cancelled
{
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Warning,
title: "Cannot copy the container's Python libraries".into(),
body: Some(e.message),
});
}
});
}
async fn update(dirs: Dirs, project: &Arc<Project>, force: bool) -> IdeResult<()> {
let settings = project.settings.effective();
let Some(id) = settings.languages.python.run_target() else { return Ok(()) };
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind == RunTargetKind::Ssh {
return Err(IdeError::invalid(format!(
"Run target '{}' runs over SSH: only a Docker or Compose run target can be the project interpreter",
target.name
)));
}
let dir = dirs.target(&project.root, id);
if !UPDATING.lock().unwrap_or_else(|e| e.into_inner()).insert(dir.clone()) {
return Ok(());
}
let _done = Updating(dir.clone());
let endpoint = crate::ops::run::container::endpoint(&dirs.data, &target)?;
let task = TaskId::next();
let title = format!("Python libraries of {}", target.name);
let (root, sink) = (project.root.clone(), project.sink.clone());
let progress = move |message: &str, done: bool| {
sink.emit(IdeEvent::Task {
id: task,
title: title.clone(),
message: Some(message.to_owned()),
fraction: None,
done,
cancellable: false,
})
};
let report = progress.clone();
let result = tokio::task::spawn_blocking(move || {
take(&endpoint, &root, &target, &dir, &dirs.cache, force, &|message| report(message, false))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))
.and_then(|r| r);
progress(if result.is_ok() { "Done" } else { "Failed" }, true);
if result? {
project.lsp.reconfigure().await;
}
Ok(())
}
struct Updating(PathBuf);
impl Drop for Updating {
fn drop(&mut self) {
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
}
}
/// Removes the probe container, also when copying failed.
struct Container<'a> {
endpoint: &'a ide_docker::DockerEndpoint,
name: String,
}
impl Drop for Container<'_> {
fn drop(&mut self) {
let args = ["rm", "--force", "--volumes", &self.name].map(String::from);
if let Err(e) = ide_docker::cli(self.endpoint, None, &args, QUICK, None) {
tracing::warn!("cannot remove container {}: {}", self.name, e.message);
}
}
}
/// Takes the snapshot in `target_dir` and makes it the one in use; `false` when the image
/// had one already and it is in use (unless `force`).
fn take(
endpoint: &ide_docker::DockerEndpoint,
root: &Path,
target: &RunTarget,
target_dir: &Path,
cache: &Path,
force: bool,
progress: &dyn Fn(&str),
) -> IdeResult<bool> {
crate::ops::run::container::require_local(endpoint, target)?;
let container_root = crate::ops::run::container::container_root(endpoint, root, target)?;
let name = format!("openidea-python-{}", unique());
let args = ide_remote::docker::python_probe_args(target, root, &name, snapshots::PROBE)?;
progress("Starting the container's Python");
let container = Container { endpoint, name };
let out = ide_docker::cli(endpoint, Some(root), &args, PROBE_TIMEOUT, None);
let probe = snapshots::parse_probe(&out?)?;
let inspect = ["inspect".into(), "--format".into(), "{{.Image}}".into(), container.name.clone()];
let image = ide_docker::cli(endpoint, None, &inspect, QUICK, None)?.trim().to_owned();
if image.is_empty() {
return Err(IdeError::internal(format!("docker inspect printed no image for {}", container.name)));
}
let dir = target_dir.join(snapshots::dir_name(&image));
let before = snapshots::current(target_dir).map(|(_, s)| s.image);
if !force && snapshots::read(&dir).is_some() {
snapshots::make_current(target_dir, &image)?;
return Ok(before.as_deref() != Some(image.as_str()));
}
let partial = target_dir.join(format!("{}.partial", snapshots::dir_name(&image)));
if partial.exists() {
std::fs::remove_dir_all(&partial)?;
}
std::fs::create_dir_all(&partial)?;
let mut folders = Vec::new();
for folder in snapshots::folders_to_copy(&probe, &container_root) {
progress(&format!("Copying {folder}"));
let local = snapshots::local_folder(&partial, &folder);
let Some(parent) = local.parent() else { continue };
std::fs::create_dir_all(parent)?;
// Into an existing folder: `docker cp` puts the source folder in it under its own name.
let args = ["cp".into(), format!("{}:{folder}", container.name), format!("{}/", parent.to_string_lossy())];
match ide_docker::cli(endpoint, None, &args, COPY_TIMEOUT, None) {
Ok(_) => folders.push(folder),
Err(e) => tracing::warn!("cannot copy {folder} from {}: {}", container.name, e.message),
}
}
drop(container);
progress("Preparing the language server's interpreter");
let snapshot = Snapshot {
neutral_python: snapshots::neutral_python(cache, &probe.version),
image: image.clone(),
probe,
folders,
};
snapshots::write(&partial, &snapshot)?;
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
std::fs::rename(&partial, &dir)?;
snapshots::make_current(target_dir, &image)?;
Ok(true)
}
/// Distinguishes probe containers.
fn unique() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNT: AtomicU32 = AtomicU32::new(0);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
format!("{:x}-{}-{}", secs & 0xff_ffff, std::process::id(), COUNT.fetch_add(1, Ordering::Relaxed))
}
+37 -2
Переглянути файл
@@ -77,9 +77,22 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
Ok(())
}
/// Largest side of a plugin diff, in UTF-16 code units (earlier hosts allowed 1 MiB).
pub(super) const MAX_DIFF_SIDE: usize = 8 << 20;
fn diff_side(value: &str, side: &str) -> Result<(), HostError> {
if value.contains('\0') {
return Err(HostError::Invalid(format!("the {side} side of the diff contains NUL")));
}
if value.encode_utf16().count() > MAX_DIFF_SIDE {
return Err(HostError::Invalid(format!("diff too large: the {side} side is over {} MiB", MAX_DIFF_SIDE >> 20)));
}
Ok(())
}
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
body(&diff.left)?;
body(&diff.right)?;
diff_side(&diff.left, "left")?;
diff_side(&diff.right, "right")?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
@@ -571,3 +584,25 @@ impl PluginHost {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn diff(left: String, right: String) -> ide_plugin::ContentDiff {
ide_plugin::ContentDiff { left, right, left_label: None, right_label: None, title: None }
}
#[test]
fn diff_sides_up_to_the_limit_open_and_larger_ones_say_why() {
assert!(validate_diff(&diff("x".repeat(5 << 20), "y".repeat(MAX_DIFF_SIDE))).is_ok());
let err = validate_diff(&diff(String::new(), "y".repeat(MAX_DIFF_SIDE + 1))).unwrap_err();
assert!(
matches!(&err, HostError::Invalid(m) if m == "diff too large: the right side is over 8 MiB"),
"{err:?}"
);
assert!(
matches!(validate_diff(&diff("a\0".into(), String::new())), Err(HostError::Invalid(m)) if m.contains("NUL"))
);
}
}
+8 -1
Переглянути файл
@@ -174,6 +174,7 @@ impl Host {
let root = member.as_ref().unwrap_or(window_root);
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
crate::ops::sdk::container_python::configure_server(cfg, &data_dir, root, &effective);
if cfg.name == "jdtls" {
// A workspace member keeps its build model under its own key (Build Project).
let cache = match &member {
@@ -630,6 +631,7 @@ impl Host {
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
crate::ops::sdk::container_python::ensure(self, &project);
Ok(project)
}
@@ -1456,7 +1458,7 @@ fn content_root_watcher(
) -> Option<Watcher> {
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
let root = dir.to_path_buf();
let (root, events) = (dir.to_path_buf(), sink.clone());
Watcher::start(
dir,
excludes.to_vec(),
@@ -1478,6 +1480,11 @@ fn content_root_watcher(
}
documents.on_disk_changed(&[&changed[..], &ignored].concat());
record_external_changes(&history, &changed, &batch.removed);
// A member outside the project folder: its repository changes (commit, checkout,
// fetch, pull) reach the UI only through this watcher.
if batch.vcs.iter().any(|p| vcs_path_changed(p)) {
events.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}
}),
)
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
+1
Переглянути файл
@@ -289,6 +289,7 @@ impl crate::Host {
}
}
project.lsp.reconfigure().await;
crate::ops::sdk::container_python::ensure(self, &project);
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
}
Ok(())
+174
Переглянути файл
@@ -0,0 +1,174 @@
//! A Compose run target as the Python interpreter, against a real daemon and the real
//! basedpyright. Ignored by default: `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the
//! daemon can bind-mount (on this machine, or at the same path in a VM such as OrbStack's),
//! `docker` with the Compose plugin and `basedpyright-langserver` must be on `PATH`, and
//! this machine's Python must not have Django installed.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
type Events = Arc<Mutex<Vec<IdeEvent>>>;
fn host(base: &Path, events: &Events) -> Arc<Host> {
Arc::new(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({
let events = events.clone();
move |_| {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
}
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
/// basedpyright's latest diagnostics of `path` once `done` accepts them.
async fn diagnostics(events: &Events, path: &Path, timeout: Duration, done: impl Fn(&[String]) -> bool) -> Vec<String> {
let deadline = Instant::now() + timeout;
loop {
let latest = events.lock().unwrap().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path: p, items, source } if **p == *path && source.contains("basedpyright") => {
Some(items.iter().map(|d| d.message.clone()).collect::<Vec<_>>())
}
_ => None,
});
if let Some(messages) = &latest
&& done(messages)
{
return messages.clone();
}
assert!(Instant::now() < deadline, "timed out; last diagnostics: {latest:?}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
fn unresolved(messages: &[String]) -> bool {
messages.iter().any(|m| m.contains("\"django") && m.contains("could not be resolved"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "requires a Docker daemon, basedpyright and OPENIDEA_DOCKER_TEST_DIR"]
async fn compose_target_is_the_python_interpreter() {
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
let root = dunce::canonicalize(root).unwrap();
let files = [
("compose.yaml", "services:\n web:\n build: .\n volumes:\n - .:/app\n working_dir: /app\n"),
(
"Dockerfile",
"FROM python:3.12-slim\nCOPY requirements.txt /tmp/requirements.txt\n\
RUN pip install --no-cache-dir -r /tmp/requirements.txt\n",
),
("requirements.txt", "Django==5.1.3\n"),
(
"app/main.py",
"import django\nfrom django.http import HttpResponse\n\nprint(django.get_version(), HttpResponse)\n",
),
];
for (path, text) in files {
std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
std::fs::write(root.join(path), text).unwrap();
}
let main = root.join("app/main.py");
let state = tempfile::tempdir().unwrap();
let events: Events = Arc::default();
let host = host(state.path(), &events);
host.trust.decide(&root, true, false).unwrap();
host.open_project(WINDOW, &root).unwrap();
let set = |key: &str, value: serde_json::Value| {
ops::project::set_settings(
&host,
WINDOW,
ide_workspace::settings::SettingsScope::Project,
key,
&value.to_string(),
)
.unwrap();
};
set("runTargets", serde_json::json!([{ "kind": "compose", "id": "web", "name": "Compose web", "service": "web" }]));
// A local interpreter: Django is only in the image.
ops::doc::open(&host, WINDOW, main.clone()).await.unwrap();
let before = diagnostics(&events, &main, Duration::from_secs(120), unresolved).await;
eprintln!("local interpreter: {before:?}");
// The run target as the interpreter: a snapshot is taken in the background, then the
// server restarts with the copied site-packages as `extraPaths`.
set("languages.python.interpreter", serde_json::json!("target:web"));
let after = diagnostics(&events, &main, Duration::from_secs(900), |m| !unresolved(m)).await;
eprintln!("run target interpreter: {after:?}");
assert!(after.iter().all(|m| !m.contains("could not be resolved")), "{after:?}");
let info = ops::sdk::sdk_container_python(&host, WINDOW).unwrap().expect("a run target interpreter");
eprintln!("snapshot: version {:?}, image {:?}, folders {:?}", info.version, info.image, info.folders);
assert_eq!(info.name, "Compose web");
assert!(info.version.as_deref().is_some_and(|v| v.starts_with("3.12.")), "{info:?}");
assert!(info.packages.iter().any(|p| p.name == "Django" && p.version == "5.1.3"), "{:?}", info.packages);
let site = info.folders.iter().find(|f| f.0.ends_with("site-packages")).expect("site-packages copied");
assert!(site.0.join("django/__init__.py").is_file());
assert!(site.0.starts_with(state.path().join("data/container-python")), "{site:?}");
let servers = ops::lsp::lsp_servers(&host, WINDOW).unwrap();
eprintln!("servers: {:?}", servers.iter().map(|s| (&s.name, s.status)).collect::<Vec<_>>());
// Run: a Python configuration without its own target runs in the container.
let out = Arc::new(Mutex::new(String::new()));
let sink = out.clone();
let config = RunConfig {
id: "main".into(),
name: "main".into(),
kind: "python".into(),
command: "python3".into(),
args: vec!["app/main.py".into()],
cwd: None,
env: Default::default(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
ops::run::start(
&host,
WINDOW,
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().unwrap().push_str(&text)
}
}),
)
.await
.unwrap();
for _ in 0..1200 {
if out.lock().unwrap().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let out = out.lock().unwrap().clone();
eprintln!("run output: {out}");
assert!(out.contains("5.1.3") && out.contains("exit code 0"), "{out}");
// Refresh takes a new snapshot of the same image; nothing is left running.
let refreshed = ops::sdk::sdk_container_python_refresh(&host, WINDOW).await.unwrap().unwrap();
assert_eq!(refreshed.image, info.image);
let probes = Command::new("docker")
.args(["ps", "--all", "--quiet", "--filter", "label=openidea.python-probe=true"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&probes.stdout).trim(), "", "probe containers are removed");
Command::new("docker").args(["compose", "down", "--rmi", "local"]).current_dir(&root).status().unwrap();
}
+22
Переглянути файл
@@ -599,6 +599,28 @@ impl Store {
txn.commit().map_err(err)
}
/// [`Self::apply`] for records already serialized (by the parse pool).
pub(crate) fn apply_raw(&self, puts: &[(String, Vec<u8>)], deletes: &[String], durable: bool) -> Result<()> {
self.ensure_db()?;
let db = self.db.read();
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
if !durable {
txn.set_durability(Durability::None).map_err(err)?;
}
{
let mut table = txn.open_table(FILES).map_err(err)?;
for (rel, bytes) in puts {
table.insert(rel.as_str(), bytes.as_slice()).map_err(err)?;
}
for rel in deletes {
table.remove(rel.as_str()).map_err(err)?;
}
}
// Any file change makes the columnar start-up snapshot stale.
let _ = std::fs::remove_file(&self.snapshot);
txn.commit().map_err(err)
}
/// Reclaims free pages in the database file; `true` if anything was compacted.
pub(crate) fn compact(&self) -> Result<bool> {
self.ensure_db()?;
+164 -48
Переглянути файл
@@ -17,6 +17,8 @@ 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;
/// Insert/persist batch of the initial scan's streaming pass.
const SCAN_BATCH: usize = 2048;
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
#[cfg(not(test))]
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
@@ -436,10 +438,13 @@ impl Shared {
});
let filter = self.filter();
let walk_started = Instant::now();
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
let walk = walk_started.elapsed();
if self.is_cancelled() {
return self.end_scan(task);
}
let classify_started = Instant::now();
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
let d = self.data.read();
@@ -452,6 +457,7 @@ impl Shared {
.collect();
(todo, stale)
};
let classify = classify_started.elapsed();
if !stale.is_empty() {
let mut d = self.data.write();
for path in &stale {
@@ -462,33 +468,76 @@ impl Shared {
self.persist(&[], &stale, false);
let total = todo.len();
let mut done = 0usize;
let mut last_progress = Instant::now();
for chunk in todo.chunks(CHUNK) {
if self.is_cancelled() {
return self.end_scan(task);
}
self.index_files(chunk.iter().copied(), false);
done += chunk.len();
if last_progress.elapsed() >= PROGRESS_EVERY {
last_progress = Instant::now();
self.sink.emit(IdeEvent::Task {
id: task,
title: "Indexing".into(),
message: Some(format!("{done} / {total} files")),
fraction: Some(done as f32 / total.max(1) as f32),
done: false,
cancellable: false,
let mut laps = ChunkLaps::default();
{
// One streaming parallel pass: parse on the rayon pool without
// per-chunk joins (a straggler file at a join's tail idles every
// other core), while parsed files flow to this thread to be
// inserted and persisted in batches. `filter` moves into the
// parse thread; this thread keeps the outer one.
let filter = self.filter();
// Bounded: when inserting and persisting fall behind, the parse waits instead
// of holding the whole project's records (and their bytes) in the channel.
let (tx, rx) = std::sync::mpsc::sync_channel::<ParsedFile>(2 * SCAN_BATCH);
let cancelled = &self.cancelled;
let mut done = 0usize;
let mut last_progress = Instant::now();
let pass_started = Instant::now();
std::thread::scope(|scope| -> () {
scope.spawn(move || {
todo.par_iter().for_each(|f| {
if cancelled.load(Ordering::Relaxed) {
return;
}
let Some(out) = self.parse_found(f, &filter, false) else { return };
let _ = tx.send(out);
});
// `tx` drops here: the consumer's loop ends with the channel.
});
}
let mut batch: Vec<ParsedFile> = Vec::with_capacity(SCAN_BATCH);
for out in rx {
if self.is_cancelled() {
break;
}
batch.push(out);
if batch.len() < SCAN_BATCH {
continue;
}
done += batch.len();
laps.add(self.publish(batch.drain(..)));
if last_progress.elapsed() >= PROGRESS_EVERY {
last_progress = Instant::now();
self.sink.emit(IdeEvent::Task {
id: task,
title: "Indexing".into(),
message: Some(format!("{done} / {total} files")),
fraction: Some(done as f32 / total.max(1) as f32),
done: false,
cancellable: false,
});
}
}
if !batch.is_empty() && !self.is_cancelled() {
done += batch.len();
laps.add(self.publish(batch.drain(..)));
}
});
laps.extract = pass_started.elapsed();
}
if self.is_cancelled() {
return self.end_scan(task);
}
let durable_started = Instant::now();
self.persist(&[], &[], true);
let durable = durable_started.elapsed();
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();
let snapshot_started = Instant::now();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
tracing::info!("index snapshot: written in {:?}", snapshot_started.elapsed());
if let Err(e) = store.save_recent_file(&recent) {
tracing::warn!("index recent snapshot: {e}");
}
@@ -506,54 +555,85 @@ impl Shared {
self.emit_state();
let d = self.data.read();
tracing::info!(
"indexed {} files ({} parsed, {} symbols) in {:?}",
"indexed {} files ({} parsed, {} symbols) in {:?} (walk {:?}, classify {:?}, extract {:?}, insert {:?}, persist {:?}, durable {:?})",
d.file_count,
total,
d.symbol_count,
started.elapsed()
started.elapsed(),
walk,
classify,
laps.extract,
laps.insert,
laps.persist,
durable
);
}
/// Extracts records in parallel, then publishes them to memory and disk.
/// `force` re-parses files whose content is unchanged (their language changed).
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) {
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) -> ChunkLaps {
let mut laps = ChunkLaps::default();
let files: Vec<&Found> = files.collect();
let filter = self.filter();
let extracted: Vec<(String, FileRecord, PathBuf)> = files
.par_iter()
.filter_map(|f| {
if self.is_cancelled() {
return None;
}
let rel = filter.rel(&f.path)?;
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
match self.extract(f, prev_hash) {
Extracted::Unchanged => {
// Same content, new mtime: keep the symbols, refresh metadata.
let mut rec = self.data.read().record_of(&f.path)?;
rec.mtime = f.mtime;
rec.size = f.size;
rec.ignored = f.ignored;
Some((rel, rec, f.path.clone()))
}
Extracted::New(rec) => Some((rel, rec, f.path.clone())),
}
})
.collect();
let started = Instant::now();
let extracted: Vec<ParsedFile> = files.par_iter().filter_map(|f| self.parse_found(f, &filter, force)).collect();
laps.extract = started.elapsed();
if extracted.is_empty() {
return;
return laps;
}
laps.add(self.publish(extracted.into_iter()));
laps
}
/// Reads and parses one file (parallel-safe), serializing its record for
/// the cache alongside the parse. `None` when the file is outside the
/// scope or the scan was cancelled.
fn parse_found(&self, f: &Found, filter: &std::sync::Arc<PathFilter>, force: bool) -> Option<ParsedFile> {
if self.is_cancelled() {
return None;
}
let rel = filter.rel(&f.path)?;
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
let rec = match self.extract(f, prev_hash) {
Extracted::Unchanged => {
// Same content, new mtime: keep the symbols, refresh metadata.
let mut rec = self.data.read().record_of(&f.path)?;
rec.mtime = f.mtime;
rec.size = f.size;
rec.ignored = f.ignored;
rec
}
Extracted::New(rec) => rec,
};
let key = filter.store_key(&f.path);
let bytes = postcard::to_allocvec(&rec).ok();
Some(ParsedFile { rel, rec, key, bytes, path: f.path.clone() })
}
/// Inserts a parsed batch into the in-memory index and persists the records.
fn publish(&self, batch: impl Iterator<Item = ParsedFile>) -> ChunkLaps {
let mut laps = ChunkLaps::default();
let started = Instant::now();
let mut entries: Vec<(FileEntry, Vec<StoredSymbol>)> = Vec::new();
let mut puts: Vec<(String, Vec<u8>)> = Vec::new();
for out in batch {
let ParsedFile { rel, rec, key, bytes, path } = out;
if let (Some(key), Some(bytes)) = (key, bytes) {
puts.push((key, bytes));
}
entries.push(FileEntry::new(path, rel, rec));
}
let puts: Vec<(String, FileRecord)> =
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
{
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
let mut d = self.data.write();
for (e, symbols) in entries {
d.insert(e, symbols);
}
}
self.persist(&puts, &[], false);
laps.insert = started.elapsed();
let started = Instant::now();
self.persist_raw(&puts, &[], false);
laps.persist = started.elapsed();
laps
}
fn schedule_snapshot(&self) {
@@ -594,6 +674,15 @@ impl Shared {
}
}
/// [`Self::persist`] for records already serialized by the parse pool.
fn persist_raw(&self, puts: &[(String, Vec<u8>)], deletes: &[String], durable: bool) {
if let Some(store) = &self.store
&& let Err(e) = store.apply_raw(puts, deletes, durable)
{
tracing::warn!("index cache write failed: {e}");
}
}
fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted {
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ignored: f.ignored, ..Default::default() };
if f.ignored || f.size > MAX_SYMBOL_FILE {
@@ -793,6 +882,33 @@ enum Extracted {
New(FileRecord),
}
/// Wall-time laps of one `index_files` chunk, accumulated by the scan.
#[derive(Default)]
struct ChunkLaps {
extract: Duration,
insert: Duration,
persist: Duration,
}
impl ChunkLaps {
fn add(&mut self, other: ChunkLaps) {
self.extract += other.extract;
self.insert += other.insert;
self.persist += other.persist;
}
}
/// One file parsed off the index thread: its record ready to publish, already
/// serialized for the cache (in parallel with the parse).
struct ParsedFile {
rel: String,
rec: FileRecord,
/// Cache key and postcard bytes; `None` when outside the persisted root.
key: Option<String>,
bytes: Option<Vec<u8>>,
path: PathBuf,
}
#[cfg(test)]
mod tests {
use crate::data::safe_rel;
+5
Переглянути файл
@@ -28,6 +28,7 @@ pub use syntax::{
VueComponentImport, VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility,
set_external_syntax, shift_pos, shift_range,
};
pub use syntax::{MethodBody, PythonClassFacts, PythonFacts, PythonMethodFacts};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -198,6 +199,10 @@ pub struct LanguageServerConfig {
/// it parses a document.
#[serde(default)]
pub resync_on_configuration: bool,
/// Set by the settings hook: what the user sees as the server when another program serves
/// in its place (TypeScript 7's own `tsc --lsp` for vtsls).
#[serde(skip)]
pub display_name: Option<String>,
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
+6 -1
Переглянути файл
@@ -43,7 +43,7 @@ fn extract(tree: &Tree, text: &str) -> Vec<ClassBases> {
// Selection-range start of the class: its name identifier (`class Foo`
// -> `Foo`), matching `outline.rs` and the gutter marker candidates.
let range = idx.range(name_node.start_byte(), name_node.end_byte());
let bases = node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default();
let bases = class_base_names(node, text);
out.push(ClassBases { name, line: range.start.line, col: range.start.col, bases });
});
out
@@ -67,6 +67,11 @@ fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
}
}
/// The bare base names a `class_definition` node writes (keyword arguments left out).
pub(super) fn class_base_names(node: Node, text: &str) -> Vec<String> {
node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default()
}
fn base_names(args: Node, text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cursor = args.walk();
+2
Переглянути файл
@@ -10,6 +10,7 @@ mod inheritance;
pub(crate) mod lines;
mod literals;
mod outline;
mod python_facts;
mod rst;
mod selection;
mod sfc;
@@ -27,6 +28,7 @@ pub use inheritance::ClassBases;
pub use lines::{shift_pos, shift_range};
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
pub use outline::merge_visibility;
pub use python_facts::{MethodBody, PythonClassFacts, PythonFacts, PythonMethodFacts};
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::OnceLock;
+438
Переглянути файл
@@ -0,0 +1,438 @@
//! Python class facts the inheritance inspections need: base names as written, parameter
//! lists, decorator semantics and what a method body does. They come from the syntax tree in
//! one pass, so the frontend reasons about the inheritance graph without parsing source text.
//!
//! Deliberately small: a fact exists here only when a check can decide it without guessing.
use super::lines::LineIndex;
use crate::{LanguageId, Syntax};
use serde::{Deserialize, Serialize};
use specta::Type;
use tree_sitter::{Node, Tree};
/// One Python module's classes, in source order.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PythonFacts {
pub classes: Vec<PythonClassFacts>,
}
/// A class definition and the methods it declares.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PythonClassFacts {
pub name: String,
/// The class name identifier (selection range).
pub selection: crate::Range,
/// Base names as written, a qualified name reduced to its last segment.
pub bases: Vec<String>,
pub methods: Vec<PythonMethodFacts>,
}
/// A method definition the inspections can reason about.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PythonMethodFacts {
pub name: String,
/// The method name identifier (selection range).
pub selection: crate::Range,
/// Parameter names in order; `None` for a form whose forwarding cannot be compared
/// (`*args`, `**kwargs`, keyword-only, positional-only).
pub params: Option<Vec<String>>,
/// Decorated with `abstractmethod` (`abc.abstractmethod` included).
pub abstract_method: bool,
/// Decorated with `override` from `typing` / `typing_extensions`.
pub override_decorated: bool,
pub body: MethodBody,
}
/// What a method body does, for the shapes a check can decide without guessing.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum MethodBody {
/// `pass`, `...`, a docstring, or nothing at all.
Empty,
/// Exactly one `super().name(args)` call, optionally `return`ed.
SuperCall { returns: bool, target: String, args: Vec<String> },
/// Anything else.
Other,
}
impl Syntax {
/// The classes of a Python `text` with the facts the inheritance checks need.
/// Every other language returns an empty result.
pub fn python_facts(&self, lang: &LanguageId, text: &str) -> PythonFacts {
self.analyze(lang, text, |g, tree, text| (g.spec.name == "python").then(|| extract(tree, text)))
.unwrap_or_default()
}
}
fn extract(tree: &Tree, text: &str) -> PythonFacts {
let idx = LineIndex::new(text);
let imports = ImportBindings::collect(tree, text);
let mut classes = Vec::new();
let mut cursor = tree.walk();
visit(&mut cursor, &mut |node| {
if node.kind() != "class_definition" {
return;
}
let Some(name_node) = node.child_by_field_name("name") else { return };
let name = text_of(name_node, text);
if name.is_empty() {
return;
}
classes.push(PythonClassFacts {
name,
selection: idx.range(name_node.start_byte(), name_node.end_byte()),
bases: super::inheritance::class_base_names(node, text),
methods: methods_of(node, text, &idx, &imports),
});
});
PythonFacts { classes }
}
/// The methods declared directly in `class`.
fn methods_of(class: Node, text: &str, idx: &LineIndex, imports: &ImportBindings) -> Vec<PythonMethodFacts> {
let Some(body) = class.child_by_field_name("body") else { return Vec::new() };
let mut out = Vec::new();
let mut cursor = body.walk();
for member in body.named_children(&mut cursor) {
// A decorated method is a `decorated_definition` wrapping the `function_definition`.
let method = match member.kind() {
"function_definition" => member,
"decorated_definition" => match member.child_by_field_name("definition") {
Some(inner) if inner.kind() == "function_definition" => inner,
_ => continue,
},
_ => continue,
};
let Some(name_node) = method.child_by_field_name("name") else { continue };
let name = text_of(name_node, text);
if name.is_empty() {
continue;
}
let decorators = decorators_of(method, text);
out.push(PythonMethodFacts {
name,
selection: idx.range(name_node.start_byte(), name_node.end_byte()),
params: parameters_of(method, text),
abstract_method: decorators.iter().any(|d| last_segment(d) == "abstractmethod"),
override_decorated: decorators.iter().any(|d| imports.is_typing_override(d)),
body: body_of(method, text),
});
}
out
}
/// The parameter names of a `function_definition`, or `None` when the signature contains a
/// form whose forwarding cannot be compared without guessing.
fn parameters_of(function: Node, text: &str) -> Option<Vec<String>> {
let params = function.child_by_field_name("parameters")?;
let mut names = Vec::new();
let mut cursor = params.walk();
for param in params.named_children(&mut cursor) {
let name = match param.kind() {
"identifier" => text_of(param, text),
"typed_parameter" | "default_parameter" | "typed_default_parameter" => {
let named = param
.child_by_field_name("name")
.or_else(|| param.child_by_field_name("pattern"))
.or_else(|| param.named_child(0))?;
if named.kind() != "identifier" {
return None;
}
text_of(named, text)
}
// `*args`, `**kwargs`, `/`, `*`: the call form cannot be compared textually.
_ => return None,
};
if !name.is_empty() {
names.push(name);
}
}
Some(names)
}
/// A `function_definition`'s body, classified into the shapes a check can decide.
fn body_of(function: Node, text: &str) -> MethodBody {
let Some(body) = function.child_by_field_name("body") else { return MethodBody::Empty };
// A docstring is not a statement the checks care about.
let statements: Vec<Node> = statements_of(body).into_iter().filter(|s| !is_string(*s)).collect();
if statements.is_empty() || statements.iter().all(|s| is_noop(*s)) {
return MethodBody::Empty;
}
let [only] = statements.as_slice() else { return MethodBody::Other };
let call = match only.kind() {
"expression_statement" => (only.named_child(0), false),
"return_statement" => (only.named_child(0), true),
_ => (None, false),
};
match call {
(Some(call), returns) if is_super_call(call, text) => {
let target = call
.child_by_field_name("function")
.and_then(|f| f.child_by_field_name("attribute"))
.map(|a| text_of(a, text))
.unwrap_or_default();
let args = call
.child_by_field_name("arguments")
.map(|args| {
let mut cursor = args.walk();
args.named_children(&mut cursor).map(|a| text_of(a, text)).collect()
})
.unwrap_or_default();
MethodBody::SuperCall { returns, target, args }
}
_ => MethodBody::Other,
}
}
/// Whether `node` is a `super().name(...)` call.
fn is_super_call(node: Node, text: &str) -> bool {
if node.kind() != "call" {
return false;
}
let Some(function) = node.child_by_field_name("function") else { return false };
if function.kind() != "attribute" {
return false;
}
function.child_by_field_name("object").is_some_and(|object| {
object.kind() == "call"
&& object
.child_by_field_name("function")
.is_some_and(|f| f.kind() == "identifier" && text_of(f, text) == "super")
})
}
/// Whether `statement` is `pass` or a bare `...`.
fn is_noop(statement: Node) -> bool {
match statement.kind() {
"pass_statement" => true,
"expression_statement" => statement.named_child(0).is_some_and(|child| child.kind() == "ellipsis"),
_ => false,
}
}
/// A string expression statement: a docstring.
fn is_string(statement: Node) -> bool {
statement.kind() == "expression_statement" && statement.named_child(0).is_some_and(|child| child.kind() == "string")
}
/// The statements of a `block`, comments excluded.
fn statements_of(block: Node) -> Vec<Node> {
let mut cursor = block.walk();
block.named_children(&mut cursor).filter(|child| child.kind() != "comment").collect()
}
/// The decorators written on a definition, without the `@` or the call arguments.
fn decorators_of(definition: Node, text: &str) -> Vec<String> {
let Some(parent) = definition.parent() else { return Vec::new() };
if parent.kind() != "decorated_definition" {
return Vec::new();
}
let mut out = Vec::new();
let mut cursor = parent.walk();
for decorator in parent.named_children(&mut cursor) {
if decorator.kind() != "decorator" {
continue;
}
let Some(inner) = decorator.named_child(0) else { continue };
let target =
if inner.kind() == "call" { inner.child_by_field_name("function").unwrap_or(inner) } else { inner };
let name = text_of(target, text).trim().to_owned();
if !name.is_empty() {
out.push(name);
}
}
out
}
/// The last segment of a dotted name (`typing.override` -> `override`).
fn last_segment(name: &str) -> &str {
name.rsplit('.').next().unwrap_or(name)
}
fn text_of(node: Node, text: &str) -> String {
text.get(node.byte_range()).unwrap_or_default().to_owned()
}
/// The module-level `from ... import ...` bindings, so `override` can be told apart from a
/// same-named decorator of another library.
struct ImportBindings {
/// (local name, module, exported name)
bindings: Vec<(String, String, String)>,
}
impl ImportBindings {
fn collect(tree: &Tree, text: &str) -> Self {
let mut bindings = Vec::new();
let mut cursor = tree.walk();
visit(&mut cursor, &mut |node| {
if node.kind() != "import_from_statement" {
return;
}
let Some(module) = node.child_by_field_name("module_name") else { return };
let module = text_of(module, text);
let mut names = node.walk();
for name in node.children_by_field_name("name", &mut names) {
let (local, exported) = match name.kind() {
"aliased_import" => (
name.child_by_field_name("alias").map(|n| text_of(n, text)),
name.child_by_field_name("name").map(|n| text_of(n, text)),
),
_ => {
let written = text_of(name, text);
(Some(written.clone()), Some(written))
}
};
if let (Some(local), Some(exported)) = (local, exported) {
bindings.push((local, module.clone(), exported));
}
}
});
Self { bindings }
}
/// Whether `decorator` names `override` from `typing` / `typing_extensions`.
fn is_typing_override(&self, decorator: &str) -> bool {
const MODULES: [&str; 2] = ["typing", "typing_extensions"];
let segments: Vec<&str> = decorator.split('.').filter(|s| !s.is_empty()).collect();
match segments.as_slice() {
// `@typing.override`, or `@t.override` after `import typing as t`.
[prefix, "override"] => {
MODULES.contains(prefix)
|| self
.bindings
.iter()
.find(|(local, _, _)| local == prefix)
.is_some_and(|(_, module, _)| MODULES.contains(&module.as_str()))
}
// `@override`, or `@ov` after `from typing import override as ov`.
[local] => match self.bindings.iter().find(|(bound, _, _)| bound == local) {
Some((_, module, exported)) => exported == "override" && MODULES.contains(&module.as_str()),
// Nothing contradicts a bare `@override`: the usual `from typing import *`.
None => *local == "override",
},
_ => false,
}
}
}
/// Depth-first pre-order traversal of all descendants of `cursor`'s node.
fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
loop {
f(cursor.node());
if cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() {
return;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
static SYNTAX: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
fn facts(src: &str) -> PythonFacts {
SYNTAX.get_or_init(Syntax::new).python_facts(&LanguageId::new("python"), src)
}
fn method<'a>(facts: &'a PythonFacts, class: &str, method: &str) -> &'a PythonMethodFacts {
facts
.classes
.iter()
.find(|c| c.name == class)
.and_then(|c| c.methods.iter().find(|m| m.name == method))
.expect("method not found")
}
#[test]
fn classes_carry_bases_and_methods() {
let f = facts(
"class Base(ABC):\n pass\n\nclass Child(Base, pkg.Mixin):\n def run(self, x=1):\n pass\n",
);
assert_eq!(f.classes.len(), 2);
assert_eq!(f.classes[0].name, "Base");
assert_eq!(f.classes[0].bases, ["ABC"]);
assert_eq!(f.classes[1].bases, ["Base", "Mixin"]);
assert_eq!(f.classes[1].methods.len(), 1);
assert_eq!(f.classes[1].selection.start.line, 3);
assert_eq!(f.classes[1].selection.start.col, 6);
}
#[test]
fn parameters_are_names_in_order_and_none_for_splats() {
let f = facts(
"class C:\n def a(self, x: int, y=2):\n pass\n\n def b(self, *args):\n pass\n\n def c(self, *, x):\n pass\n",
);
assert_eq!(
method(&f, "C", "a").params.as_deref(),
Some(["self".to_string(), "x".to_string(), "y".to_string()].as_slice())
);
assert_eq!(method(&f, "C", "b").params, None);
assert_eq!(method(&f, "C", "c").params, None);
}
#[test]
fn body_shapes_are_classified() {
let f = facts(
"class C:\n def noop(self):\n pass\n\n def dots(self):\n ...\n\n def doc(self):\n \"\"\"text\"\"\"\n\n def forward(self, x):\n return super().run(x)\n\n def call(self):\n super().run()\n\n def other(self):\n return 1\n",
);
assert_eq!(method(&f, "C", "noop").body, MethodBody::Empty);
assert_eq!(method(&f, "C", "dots").body, MethodBody::Empty);
assert_eq!(method(&f, "C", "doc").body, MethodBody::Empty);
assert_eq!(
method(&f, "C", "forward").body,
MethodBody::SuperCall { returns: true, target: "run".into(), args: vec!["x".into()] }
);
assert_eq!(
method(&f, "C", "call").body,
MethodBody::SuperCall { returns: false, target: "run".into(), args: vec![] }
);
assert_eq!(method(&f, "C", "other").body, MethodBody::Other);
}
#[test]
fn decorators_are_their_names_and_semantics() {
let f = facts(
"from abc import abstractmethod\nfrom typing import override\n\nclass C:\n @abstractmethod\n def a(self):\n pass\n\n @override\n def b(self):\n pass\n\n @functools.cache\n def c(self):\n pass\n",
);
assert!(method(&f, "C", "a").abstract_method);
assert!(!method(&f, "C", "a").override_decorated);
assert!(method(&f, "C", "b").override_decorated);
assert!(!method(&f, "C", "c").override_decorated);
}
#[test]
fn override_of_another_library_is_not_typing_override() {
let f = facts("from mylib import override\n\nclass C:\n @override\n def a(self):\n pass\n");
assert!(!method(&f, "C", "a").override_decorated);
}
#[test]
fn aliased_override_and_qualified_forms_are_recognised() {
let f = facts(
"from typing import override as ov\n\nclass C:\n @ov\n def a(self):\n pass\n\nclass D:\n @typing_extensions.override\n def b(self):\n pass\n",
);
assert!(method(&f, "C", "a").override_decorated);
assert!(method(&f, "D", "b").override_decorated);
}
#[test]
fn nested_classes_and_other_languages() {
let f = facts("class Outer:\n class Inner:\n def m(self):\n pass\n");
let names: Vec<&str> = f.classes.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["Outer", "Inner"]);
let other = SYNTAX.get_or_init(Syntax::new).python_facts(&LanguageId::new("rust"), "class C {}");
assert_eq!(other.classes.len(), 0);
}
}
+16
Переглянути файл
@@ -3097,6 +3097,21 @@ impl LspManager {
Ok(CallHierarchy { items: out, incomplete })
}
/// The type at `pos` as the server's type hierarchy names it (with its `detail`, the
/// package for Java); `None` when there is no type there.
pub async fn prepare_type_hierarchy(&self, doc: DocId, pos: Pos) -> IdeResult<Option<HierarchyItem>> {
let (snap, targets) = self.doc_targets(doc, "type hierarchy", |c| c.has("typeHierarchyProvider")).await?;
let t = &targets[0];
let items: Option<Vec<serde_json::Value>> =
self.call(t, "textDocument/prepareTypeHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?;
let conv = self.conv(t.enc());
Ok(items
.unwrap_or_default()
.iter()
.filter_map(convert::type_item_from_json)
.find_map(|i| convert::type_item(&conv, &i)))
}
pub async fn type_hierarchy(
&self,
doc: DocId,
@@ -3855,6 +3870,7 @@ impl LspManager {
languages: s.languages(),
status: s.status(),
pid: s.running().and_then(|r| r.peer.pid()),
label: s.display_name(),
})
.collect()
}
+12 -1
Переглянути файл
@@ -213,6 +213,11 @@ impl Server {
config
}
/// The configured server's display name for the current (or last) instance.
pub fn display_name(&self) -> Option<String> {
self.effective.lock().as_ref().and_then(|c| c.display_name.clone())
}
pub fn set_effective_config(&self, generation: u64, config: LanguageServerConfig) {
let st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_)) {
@@ -507,7 +512,13 @@ impl Server {
return;
}
*cur = (status, message.clone());
st.events.push_back(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
st.events.push_back(IdeEvent::LanguageServer {
server: self.id,
name: self.name.clone(),
label: self.display_name(),
status,
message,
});
}
fn queue_cleanup(&self, st: &mut State) {
+4
Переглянути файл
@@ -341,6 +341,10 @@ pub struct ServerInfo {
pub status: ServerStatus,
/// OS process of the running instance (for memory attribution).
pub pid: Option<u32>,
/// What the user sees as the server when another program serves in its place.
#[serde(default)]
#[specta(optional)]
pub label: Option<String>,
}
/// Read-only contents a language server provides for a non-`file:` URI
+1
Переглянути файл
@@ -268,6 +268,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
}
}
+3
Переглянути файл
@@ -426,6 +426,7 @@ async fn missing_binary_is_reported_with_install_hint() {
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
@@ -698,6 +699,7 @@ async fn typescript_language_server_end_to_end() {
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
@@ -750,6 +752,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
}
}
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
0b77e7a414ec3b0dd1f41e5658b981f059dd842743dbe2f06e5d428f5b9570f9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
6f7d6527837c8e5c86c377479cceef5116decd61666b28f36dd11332e36e9934
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
c1ac86a5b4d45b55013e6e00dcb83deda33b759ecc366c3fdf57739bdd22cbff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
e4d8d8f5f5849b912b2c0ebee6b91d60796c3070929d7698323e8ce4f200a38a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
e2e2fc44ad9cae9cf26a5bf706768d3786502f23db6994dc29695deb249ed73f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
61e1f0c43bbfddc35dcf40685169eb273777ddca13da534626e1699b47e8eac3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
2ea1c1693db8638db996c9c47597f0c1635ef0a9c7dfb33685c515b7af95e6a6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
3c788f6092b229484a5d94fd0e6f4651afc88dd0ab1b22b067d84614a6a0c85e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
18273599cc459833d6a30185a5f0ba127cdb4e22bc054a973571e530eb3a85ca
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
c9fafba362fa0745d7ee1685efe5582d432510003c04c98100eddd557bd1e2cf
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
4121009913709edeca8c5cb52d88dfcc2b16867bc06859768f73c17cf9c456a2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
b55bd202a06f5643c4f70abb6fb473fbe53af657f4e21a6d13581a23a726e085
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
11a12228df67f9b94bd52375b5dddb2d955f7e5f88d7f9ea0a430b1db128f6ca
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
553ce6c57052f8ff2ea2526e88762d5a0aee2489cda192fc7ab708766078ccf7
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
7b43f8a2a06d7b2952f8d46727a4cce524fb2bdecee1035dad9c767261d276d0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
e994c69fda5eef94ae85d6dac5aec3d04fb6811c35c446c9d33236a449bd1c6c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
c37f6a7ea9018ca636d0fd486a9d91cd397158615410de9b837ca9b8f9f593b7
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
1b298dfa5c1f2163d609ef0ccc3d42fafe78e69326002effe53abc4b82974749
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
1e11a95e0909d18308d21a9a8448f8b2e427d240d5d8cd13d8ddbe6ba5009237
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d11f9cb1130f3f3aaa89514bf47ec8ece9b2ca0edf1d82c11f80b262325f16be
d5cd77130cc97cffdbc742f04e3e1196f14160107b636806c6ef3c9b40724e84
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
48ea4414469ebd70a89da8a5df7b7bddc4d32f5c50f1759ee3cea14558073c92
a0f74430b0a7d9ff48524d7aae4f8a0bd0fc60511e2a951dde9991b9bf7d15a9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0c02303182be04ed3776fb532fd8b72e6057aa0fdb92b7d235000146fef4120e
65508f6e25bd0efc85820ae7d0f9037b58c778fa61e8498c5b9a5055cc38f6fc
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8162a70e848172663463119e6357984a73e54bc71a99b2845621d0733cf9333e
c86ed41d68c132f72eb8b5794f31b975492e5ad42e41b7823d1bf28ba118a262
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4524cab0ceac631153a945f6b76f7d40efd3c854740ae22c64580381e9532397
3da1a09906d803234016b3f9c30a88b9b40bf3187fc73840ff60a2e05f504b48
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a92f4def072ca4cc805414516ab4f046fc54bcf11394a5413a27ea151c156174
39e7a805120ce6433750c32b1d670b3caba990f7b15aea866a29c23b01cba708
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
994fca61bc5a10f0229301406af76c188a9cb2f593db85a403c1ece746e9f7b5
5ec9be607955ce2aa4d921884df17c49af3ce98945f8fa641cec9a7f1b4d8384
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
48eaf97f57cc08c3854a1effa37ebf29defca4782f76c7de1da6ef9a02c23441
f05df5c2faf1718ba9702be4d979c367ea8445a854794bb21e8e36b211f18122
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b896043cf261ff4bb19977f4648a736907fa897b8d4f0804077678a5895d1845
548cb8321b63b0685049fcf796dbc8390fcb22f3c1ffcfa09452e5a63382d27d
+54 -3
Переглянути файл
@@ -109,6 +109,17 @@ pub(crate) fn cpuprofile_runs_code_of(json: &str, root: &Path) -> bool {
})
}
/// The file on disk of one `$vscode.locations` entry and its 0-based line. The entry lists a
/// frame's places in order: the source it was mapped to, then the script that ran, which a
/// page serves from a URL and has no file.
fn source_location(entry: &Value) -> Option<(PathBuf, u32)> {
entry.get("locations")?.as_array()?.iter().find_map(|l| {
let path = Path::new(l.pointer("/source/path")?.as_str()?);
let line = l.get("lineNumber")?.as_u64()?;
path.is_absolute().then(|| (path.to_path_buf(), line.saturating_sub(1) as u32))
})
}
/// Adds a V8 CPU profile (`node --cpu-prof`), weighted by sampled time in milliseconds.
pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), String> {
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
@@ -146,6 +157,8 @@ pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), Stri
}
}
}
// A recording made through a debugger (js-debug) says where each frame is in the sources.
let mapped = v.pointer("/$vscode/locations").and_then(Value::as_array);
let frame_of = |n: &Value| -> Option<FrameKey> {
let cf = n.get("callFrame")?;
let name = cf.get("functionName").and_then(Value::as_str).unwrap_or_default();
@@ -153,10 +166,22 @@ pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), Stri
if name == "(root)" {
return None;
}
let file = url_path(url);
let name = if name.is_empty() { "(anonymous)".to_owned() } else { name.to_owned() };
let line =
file.as_ref().and(cf.get("lineNumber").and_then(Value::as_i64)).filter(|l| *l >= 0).map(|l| l as u32);
let (file, line) = n
.get("locationId")
.and_then(Value::as_u64)
.and_then(|id| mapped?.get(id as usize))
.and_then(source_location)
.map(|(file, line)| (Some(file), Some(line)))
.unwrap_or_else(|| {
let file = url_path(url);
let line = file
.as_ref()
.and(cf.get("lineNumber").and_then(Value::as_i64))
.filter(|l| *l >= 0)
.map(|l| l as u32);
(file, line)
});
Some(FrameKey { name, file, line, kind: ProfileFrameKind::Code })
};
for (id, weight) in self_time {
@@ -301,6 +326,32 @@ mod tests {
assert!(add_cpuprofile(&mut TreeBuilder::new(), "{}").is_err());
}
#[test]
fn cpuprofile_frames_take_the_source_a_debugger_mapped_them_to() {
// js-debug's recording of a page: scripts are URLs, and `$vscode.locations` holds where
// each frame is in the sources (1-based lines), first the mapped file, then the script.
let json = r#"{"nodes":[
{"id":1,"callFrame":{"functionName":"(root)","url":"","lineNumber":-1},"children":[2,3,4]},
{"id":2,"callFrame":{"functionName":"increment","url":"http://localhost:5173/src/App.vue","lineNumber":40},"hitCount":3,"locationId":0},
{"id":3,"callFrame":{"functionName":"inline","url":"http://localhost:5173/","lineNumber":9},"hitCount":1,"locationId":1},
{"id":4,"callFrame":{"functionName":"(idle)","url":"","lineNumber":-1},"hitCount":1,"locationId":2}],
"$vscode":{"rootPath":"/work/shop","locations":[
{"locations":[
{"lineNumber":13,"columnNumber":10,"source":{"path":"/work/shop/src/App.vue","sourceReference":0}},
{"lineNumber":41,"columnNumber":21,"source":{"path":"localhost꞉5173/src/App.vue","sourceReference":7}}]},
{"locations":[{"lineNumber":10,"columnNumber":1,"source":{"path":"localhost꞉5173/","sourceReference":9}}]},
{"locations":[]}]}}"#;
let mut b = TreeBuilder::new();
add_cpuprofile(&mut b, json).unwrap();
let p = b.finish("node", "samples", ProfileMode::Cpu);
let frame = |name: &str| p.frames.iter().find(|f| f.name == name).unwrap();
assert_eq!(frame("increment").file.as_deref(), Some(Path::new("/work/shop/src/App.vue")));
assert_eq!(frame("increment").line, Some(12));
// No file on disk behind the frame: it stays without one.
assert_eq!((frame("inline").file.as_deref(), frame("inline").line), (None, None));
assert_eq!(frame("(idle)").file, None);
}
#[test]
fn cpuprofile_without_deltas_uses_hit_counts() {
let json = r#"{"nodes":[{"id":1,"callFrame":{"functionName":"(root)","url":""},"children":[2]},
+69
Переглянути файл
@@ -178,6 +178,47 @@ pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<Run
Ok(out)
}
/// The docker CLI arguments that run `python -c code` in a new container of the target's
/// image or service, named `name`. The container is kept when it exits, so `docker cp` can
/// copy from it while nothing runs; the caller removes it. The image's entrypoint is
/// skipped (it may wait for other services), as are the service's dependencies.
pub fn python_probe_args(target: &RunTarget, root: &Path, name: &str, code: &str) -> IdeResult<Vec<String>> {
let python = match target.python.trim() {
"" => "python3",
python => python,
};
let mut args: Vec<String> = Vec::new();
match target.kind {
RunTargetKind::Docker => {
if target.image.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name)));
}
args.push("run".into());
}
RunTargetKind::Compose => {
if target.service.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name)));
}
args.push("compose".into());
for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file".into(), root.join(file).to_string_lossy().into_owned()]);
}
args.extend(["run", "--no-deps", "--no-TTY"].map(String::from));
}
RunTargetKind::Ssh => {
return Err(IdeError::invalid(format!("Run target '{}' is not a container", target.name)));
}
}
args.extend(["--name".into(), name.to_owned(), "--label".into(), "openidea.python-probe=true".into()]);
args.extend(["--entrypoint".into(), python.to_owned()]);
args.push(match target.kind {
RunTargetKind::Compose => target.service.trim().to_owned(),
_ => target.image.trim().to_owned(),
});
args.extend(["-c".into(), code.to_owned()]);
Ok(args)
}
/// Project managers whose `run` executes in the project's environment (`uv run python …`).
const PYTHON_RUNNERS: [&str; 6] = ["uv", "poetry", "pdm", "pipenv", "hatch", "rye"];
@@ -429,6 +470,34 @@ mod tests {
assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service"));
}
#[cfg(unix)]
#[test]
fn python_probe_runs_the_interpreter_in_a_kept_container() {
let root = Path::new("/work/proj");
let target = RunTarget {
kind: RunTargetKind::Compose,
compose_files: vec!["compose.yaml".into()],
service: "web".into(),
python: "/venv/bin/python".into(),
run_options: "--rm".into(),
..Default::default()
};
assert_eq!(
python_probe_args(&target, root, "openidea-python-1", "print(1)").unwrap().join(" "),
"compose --file /work/proj/compose.yaml run --no-deps --no-TTY --name openidea-python-1 \
--label openidea.python-probe=true --entrypoint /venv/bin/python web -c print(1)"
);
let target = RunTarget { kind: RunTargetKind::Docker, image: "python:3.12".into(), ..Default::default() };
assert_eq!(
python_probe_args(&target, root, "p", "x").unwrap().join(" "),
"run --name p --label openidea.python-probe=true --entrypoint python3 python:3.12 -c x"
);
let ssh = RunTarget { name: "box".into(), ..Default::default() };
assert!(python_probe_args(&ssh, root, "p", "x").unwrap_err().message.contains("not a container"));
let empty = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() };
assert!(python_probe_args(&empty, root, "p", "x").unwrap_err().message.contains("no service"));
}
/// The container's command line of `c` debugged on `target` (after the image).
#[cfg(unix)]
fn debugged(target: &RunTarget, c: &RunConfig) -> IdeResult<Vec<String>> {
+2
Переглянути файл
@@ -68,6 +68,8 @@ fn profiles_filter_and_preserve_launch_properties() {
assert_eq!(p["http"].command_line_args, "--message \"hello world\"");
assert_eq!(p["http"].working_directory, "work");
assert!(parse_profiles("invalid").is_empty());
// Visual Studio saves launchSettings.json with a byte order mark.
assert_eq!(parse_profiles(&format!("\u{feff}{PROFILES}")).len(), 2);
}
#[test]
+16
Переглянути файл
@@ -69,7 +69,11 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<Pat
/// root, else the Python the project pins for mise / asdf. A configured
/// interpreter whose executable is gone (uninstalled, moved) is skipped like
/// PyCharm's "Invalid" SDKs, so runs fall back instead of failing on a dead path.
/// `None` when the project interpreter is a run target's (see [`crate::container_python`]).
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
if s.languages.python.run_target().is_some() {
return None;
}
setting_path(root, &s.languages.python.interpreter)
.filter(|p| p.is_file())
.or_else(|| project_venv_python(root))
@@ -78,6 +82,9 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
if s.languages.python.run_target().is_some() {
return None;
}
if let Some(python) = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file()) {
return Some(python);
}
@@ -1156,6 +1163,7 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
{
cfg.command = native.to_string_lossy().into_owned();
cfg.args = vec!["--lsp".into(), "--stdio".into()];
cfg.display_name = Some("TypeScript 7".into());
cfg.initialization_options = None;
cfg.tsserver_project = false;
return;
@@ -1299,6 +1307,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
}
}
@@ -1476,6 +1485,9 @@ pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a
|| matches!(config.kind.as_str(), "node" | "npm" | "vitest" | "jest" | "mocha")
{
&settings.languages.node.run_target
} else if let Some(target) = settings.languages.python.run_target().filter(|_| is_python_config(config, &name)) {
// The project interpreter is a run target's: Python runs there.
return Some(target);
} else {
return non_empty(&settings.run.default_target);
};
@@ -1488,6 +1500,10 @@ pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a
}
}
fn is_python_config(config: &RunConfig, program: &str) -> bool {
matches!(config.kind.as_str(), "python" | "pytest" | "django-test") || is_python_command(program)
}
/// Whether the configuration's `JAVA_HOME` is the one Settings choose (the Maven runner JRE,
/// the Gradle JVM) rather than a choice of its own: an IDE-generated build-tool configuration —
/// a Maven goal or Gradle task from the tool window — carries the model's `JAVA_HOME`, which
+418
Переглянути файл
@@ -0,0 +1,418 @@
//! A Docker or Compose run target's Python as the project interpreter
//! (`languages.python.interpreter = "target:<id>"`, PyCharm's Docker / Docker Compose
//! interpreter).
//!
//! basedpyright / pyright run on this machine, so the library folders of the
//! container's `sys.path` are copied here once per image (a snapshot) and given to
//! the server as `extraPaths`. Its interpreter is a neutral one: an empty virtualenv
//! the IDE creates, so no local site-packages mix in. Without any local Python the
//! server gets `extraPaths` alone and its bundled typeshed standard library.
//!
//! The snapshot is read-only: packages change in the image (requirements, Dockerfile)
//! and a rebuilt image gets a new snapshot. Taking one runs the docker CLI (`ide-host`).
use crate::apply::merge_init_options;
use crate::exec::{query_in, which};
use crate::libraries::{LibraryGroup, LibraryRoot};
use crate::python::PythonPackage;
use ide_core::{IdeError, IdeResult};
use ide_language::LanguageServerConfig;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Run in the container with `python -c`: prints [`PROBE_MARKER`] and a JSON
/// [`Probe`] (an entrypoint may print more around it).
pub const PROBE: &str = "import json,os,sys,sysconfig\n\
p=sysconfig.get_paths()\n\
print('OPENIDEA-PYTHON '+json.dumps({'version':'%d.%d.%d'%sys.version_info[:3],\
'executable':sys.executable,'stdlib':[p.get('stdlib'),p.get('platstdlib')],\
'path':[x for x in sys.path if x and os.path.isdir(x)]}))";
const PROBE_MARKER: &str = "OPENIDEA-PYTHON ";
/// What [`PROBE`] prints about the container's interpreter.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Probe {
/// `3.12.7`.
pub version: String,
pub executable: String,
/// `stdlib` and `platstdlib` of `sysconfig`.
pub stdlib: Vec<Option<String>>,
/// The folders of `sys.path`, in order.
pub path: Vec<String>,
}
pub fn parse_probe(out: &str) -> IdeResult<Probe> {
let json = out
.lines()
.rev()
.find_map(|l| l.trim().strip_prefix(PROBE_MARKER))
.ok_or_else(|| IdeError::internal(format!("the container's Python printed no paths: {}", out.trim())))?;
serde_json::from_str(json).map_err(|e| IdeError::internal(format!("cannot read the container's Python paths: {e}")))
}
/// The container folders a snapshot copies: the `sys.path` folders outside the standard
/// library (its stubs come with the server) and outside the project, which is here already.
/// A folder inside another one of the list is copied with it.
pub fn folders_to_copy(probe: &Probe, container_root: &str) -> Vec<String> {
let under = |path: &str, dir: &str| {
let dir = dir.trim_end_matches('/');
path == dir || path.starts_with(&format!("{dir}/")) || dir.is_empty()
};
let stdlib: Vec<&str> = probe.stdlib.iter().flatten().map(String::as_str).collect();
// `site-packages` lives inside the standard library's folder.
let packages = |path: &str| path.split('/').any(|c| c == "site-packages" || c == "dist-packages");
let mut out: Vec<String> = Vec::new();
for path in &probe.path {
let path = path.trim_end_matches('/');
if !path.starts_with('/')
|| (stdlib.iter().any(|s| under(path, s)) && !packages(path))
|| (!container_root.trim().is_empty() && under(path, container_root))
|| out.iter().any(|o| under(path, o))
{
continue;
}
out.retain(|o| !under(o, path));
out.push(path.to_owned());
}
out
}
/// Where a snapshot in `dir` keeps its copy of the container folder `container`.
pub fn local_folder(dir: &Path, container: &str) -> PathBuf {
container.split('/').filter(|c| !c.is_empty() && *c != "." && *c != "..").fold(dir.join("fs"), |p, c| p.join(c))
}
/// `snapshot.json` of a snapshot folder.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Snapshot {
/// `sha256:…` of the image the folders were copied from.
pub image: String,
pub probe: Probe,
/// The copied container folders ([`local_folder`] has them here).
pub folders: Vec<String>,
/// The neutral interpreter given to the language server; `None` without a local Python.
pub neutral_python: Option<PathBuf>,
}
const SNAPSHOT_FILE: &str = "snapshot.json";
/// Names the snapshot folder in use, in the target's folder.
const CURRENT_FILE: &str = "current";
/// A target's snapshots: `<cache>/<target>/<image>/`.
pub fn target_dir(cache: &Path, target_id: &str) -> PathBuf {
cache.join(dir_name(target_id))
}
/// The folder name of an image id or target id.
pub fn dir_name(id: &str) -> String {
let id = id.strip_prefix("sha256:").map(|hex| &hex[..hex.len().min(16)]).unwrap_or(id);
let name: String =
id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
if name.is_empty() { "_".into() } else { name }
}
/// The snapshot in use for the target whose folder is `target_dir`, with its folder.
pub fn current(target_dir: &Path) -> Option<(PathBuf, Snapshot)> {
let name = std::fs::read_to_string(target_dir.join(CURRENT_FILE)).ok()?;
let dir = target_dir.join(dir_name(name.trim()));
read(&dir).map(|s| (dir, s))
}
/// The complete snapshot in `dir`, if there is one.
pub fn read(dir: &Path) -> Option<Snapshot> {
serde_json::from_str(&std::fs::read_to_string(dir.join(SNAPSHOT_FILE)).ok()?).ok()
}
/// Writes `snapshot.json` (last: a folder without it is incomplete).
pub fn write(dir: &Path, snapshot: &Snapshot) -> IdeResult<()> {
let json = serde_json::to_string_pretty(snapshot).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(dir.join(SNAPSHOT_FILE), json)?;
Ok(())
}
/// Makes the snapshot of `image` the one in use and removes the target's other snapshots.
pub fn make_current(target_dir: &Path, image: &str) -> IdeResult<()> {
let keep = dir_name(image);
std::fs::write(target_dir.join(CURRENT_FILE), &keep)?;
for entry in std::fs::read_dir(target_dir)?.flatten() {
let path = entry.path();
if path.is_dir()
&& entry.file_name().to_string_lossy() != keep.as_str()
&& let Err(e) = std::fs::remove_dir_all(&path)
{
tracing::warn!("cannot remove the old Python snapshot {}: {e}", path.display());
}
}
Ok(())
}
/// The local folders of a snapshot that exist.
pub fn local_folders(dir: &Path, snapshot: &Snapshot) -> Vec<PathBuf> {
snapshot.folders.iter().map(|f| local_folder(dir, f)).filter(|p| p.is_dir()).collect()
}
/// basedpyright / pyright: the neutral interpreter and the snapshot's folders as
/// `extraPaths`. Without a snapshot yet the server only loses the local interpreter.
pub fn configure_server(cfg: &mut LanguageServerConfig, snapshot: Option<(&Path, &Snapshot)>) {
let section = match cfg.name.as_str() {
"basedpyright" => "basedpyright",
"pyright" => "python",
_ => return,
};
let Some((dir, snapshot)) = snapshot else { return };
if let Some(python) = snapshot.neutral_python.as_deref().filter(|p| p.is_file()) {
merge_init_options(cfg, json!({ "python": { "pythonPath": python.to_string_lossy() } }));
if let Some(venv) = crate::venv_of(python) {
cfg.env.insert("VIRTUAL_ENV".into(), venv.to_string_lossy().into_owned());
}
}
let paths: Vec<String> = local_folders(dir, snapshot).iter().map(|p| p.to_string_lossy().into_owned()).collect();
merge_init_options(cfg, json!({ "settings": { section: { "analysis": { "extraPaths": paths } } } }));
}
/// External Libraries: the snapshot's folders, named after the target.
pub fn library_group(target_name: &str, dir: &Path, snapshot: &Snapshot) -> Option<LibraryGroup> {
let short = snapshot.probe.version.split('.').take(2).collect::<Vec<_>>().join(".");
let roots: Vec<LibraryRoot> = snapshot
.folders
.iter()
.map(|f| (f, local_folder(dir, f)))
.filter(|(_, p)| p.is_dir())
.map(|(f, p)| LibraryRoot { name: f.rsplit('/').next().unwrap_or(f).to_owned(), path: p.into() })
.collect();
(!roots.is_empty()).then(|| LibraryGroup {
name: format!("Python {short} ({target_name})"),
kind: "python".into(),
roots,
})
}
/// The packages of a snapshot, from the `*.dist-info` / `*.egg-info` metadata it copied.
pub fn packages(dir: &Path, snapshot: &Snapshot) -> Vec<PythonPackage> {
let mut out: Vec<PythonPackage> = Vec::new();
for folder in local_folders(dir, snapshot) {
let Ok(entries) = std::fs::read_dir(&folder) else { continue };
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
let metadata = if name.ends_with(".dist-info") {
path.join("METADATA")
} else if name.ends_with(".egg-info") {
if path.is_dir() { path.join("PKG-INFO") } else { path }
} else {
continue;
};
let Some(package) = std::fs::read_to_string(&metadata).ok().and_then(|m| package_of(&m)) else {
continue;
};
if !out.iter().any(|p| p.name.eq_ignore_ascii_case(&package.name)) {
out.push(package);
}
}
}
out.sort_by_key(|p| p.name.to_lowercase());
out
}
fn package_of(metadata: &str) -> Option<PythonPackage> {
let header = |key: &str| {
metadata
.lines()
.take_while(|l| !l.is_empty())
.find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix(':')))
.map(|v| v.trim().to_owned())
};
Some(PythonPackage { name: header("Name")?, version: header("Version").unwrap_or_default() })
}
/// The neutral interpreter for a container Python of `version`: an empty virtualenv in
/// `cache`, made from a local Python of the same minor version when there is one. `None`
/// when this machine has no Python at all.
pub fn neutral_python(cache: &Path, version: &str) -> Option<PathBuf> {
neutral_python_with(cache, version, which)
}
pub(crate) fn neutral_python_with(
cache: &Path,
version: &str,
which: impl Fn(&str) -> Option<PathBuf>,
) -> Option<PathBuf> {
let short = version.split('.').take(2).collect::<Vec<_>>().join(".");
let named = (!short.is_empty()).then(|| format!("python{short}"));
let (base, label) = match named.as_deref().and_then(|n| which(n).map(|p| (p, short.clone()))) {
Some(found) => found,
None => (which("python3").or_else(|| which("python"))?, "default".to_owned()),
};
let venv = cache.join("neutral").join(label);
let python = venv.join(crate::bin_dir()).join(crate::exe("python"));
if python.is_file() && venv.join("pyvenv.cfg").is_file() {
return Some(python);
}
std::fs::create_dir_all(&venv).ok()?;
let location = venv.to_string_lossy().into_owned();
query_in(&base, &["-m", "venv", "--without-pip", &location], None, &[], Duration::from_secs(60));
python.is_file().then_some(python)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::server;
fn probe() -> Probe {
Probe {
version: "3.12.7".into(),
executable: "/usr/local/bin/python3".into(),
stdlib: vec![Some("/usr/local/lib/python3.12".into()), Some("/usr/local/lib/python3.12".into())],
path: [
"/workspaces/app",
"/workspaces/app/src",
"/usr/local/lib/python3.12",
"/usr/local/lib/python3.12/lib-dynload",
"/opt/lib/extra",
"/usr/local/lib/python3.12/site-packages",
"/usr/local/lib/python3.12/site-packages/setuptools/_vendor",
"relative",
]
.map(String::from)
.to_vec(),
}
}
#[test]
fn copies_library_folders_outside_the_stdlib_and_the_project() {
assert_eq!(
folders_to_copy(&probe(), "/workspaces/app"),
["/opt/lib/extra", "/usr/local/lib/python3.12/site-packages"]
);
let mut p = probe();
p.path.insert(0, "/opt/lib/extra/inner".into());
assert_eq!(folders_to_copy(&p, "/workspaces/app")[0], "/opt/lib/extra", "the outer folder replaces the inner");
}
#[test]
fn reads_the_probe_after_other_output() {
let line = format!("{PROBE_MARKER}{}", serde_json::to_string(&probe()).unwrap());
let out = format!("waiting for the entrypoint\n{line}\nbye\n");
assert_eq!(parse_probe(&out).unwrap(), probe());
assert!(parse_probe("python3: not found").unwrap_err().message.contains("not found"));
}
#[test]
fn mirrors_container_paths_and_names_folders() {
let dir = Path::new("snap");
assert_eq!(
local_folder(dir, "/usr/local/lib/../site-packages"),
Path::new("snap").join("fs").join("usr").join("local").join("lib").join("site-packages")
);
assert_eq!(dir_name("sha256:0123456789abcdef0123"), "0123456789abcdef");
assert_eq!(dir_name("target/../x y"), "target____x_y");
}
fn snapshot_in(dir: &Path, neutral: Option<PathBuf>) -> Snapshot {
let snapshot = Snapshot {
image: "sha256:abc".into(),
probe: probe(),
folders: vec!["/usr/local/lib/python3.12/site-packages".into(), "/gone".into()],
neutral_python: neutral,
};
let site = local_folder(dir, &snapshot.folders[0]);
std::fs::create_dir_all(site.join("Django-5.1.3.dist-info")).unwrap();
std::fs::write(
site.join("Django-5.1.3.dist-info/METADATA"),
"Metadata-Version: 2.1\nName: Django\nVersion: 5.1.3\n\nName: not a header\n",
)
.unwrap();
std::fs::create_dir_all(site.join("asgiref-3.8.1.dist-info")).unwrap();
std::fs::write(site.join("asgiref-3.8.1.dist-info/METADATA"), "Name: asgiref\nVersion: 3.8.1\n").unwrap();
std::fs::write(site.join("legacy.egg-info"), "Name: legacy\nVersion: 0.1\n").unwrap();
snapshot
}
#[test]
fn lists_packages_and_library_roots_of_a_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let snapshot = snapshot_in(tmp.path(), None);
let names: Vec<String> =
packages(tmp.path(), &snapshot).into_iter().map(|p| format!("{} {}", p.name, p.version)).collect();
assert_eq!(names, ["asgiref 3.8.1", "Django 5.1.3", "legacy 0.1"]);
let group = library_group("web", tmp.path(), &snapshot).unwrap();
assert_eq!(group.name, "Python 3.12 (web)");
assert_eq!(group.roots.len(), 1, "a folder that was not copied is left out");
assert_eq!(group.roots[0].name, "site-packages");
}
#[test]
fn current_snapshot_switches_and_drops_old_ones() {
let tmp = tempfile::tempdir().unwrap();
let target = target_dir(tmp.path(), "web");
for image in ["sha256:old", "sha256:new"] {
let dir = target.join(dir_name(image));
std::fs::create_dir_all(&dir).unwrap();
write(&dir, &Snapshot { image: image.into(), ..Default::default() }).unwrap();
}
std::fs::create_dir_all(target.join("new.partial")).unwrap();
assert!(current(&target).is_none());
make_current(&target, "sha256:new").unwrap();
let (dir, snapshot) = current(&target).unwrap();
assert_eq!((dir, snapshot.image.as_str()), (target.join("new"), "sha256:new"));
assert!(!target.join("old").exists() && !target.join("new.partial").exists());
}
#[test]
fn basedpyright_gets_the_neutral_interpreter_and_extra_paths() {
let tmp = tempfile::tempdir().unwrap();
let venv = tmp.path().join("neutral");
let python = venv.join(crate::bin_dir()).join(crate::exe("python"));
std::fs::create_dir_all(python.parent().unwrap()).unwrap();
std::fs::write(&python, "").unwrap();
std::fs::write(venv.join("pyvenv.cfg"), "include-system-site-packages = false\n").unwrap();
let snapshot = snapshot_in(tmp.path(), Some(python.clone()));
let site = local_folder(tmp.path(), &snapshot.folders[0]).to_string_lossy().into_owned();
let mut cfg = server("basedpyright");
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
assert_eq!(opts["python"]["pythonPath"], python.to_string_lossy().as_ref());
assert_eq!(opts["settings"]["basedpyright"]["analysis"]["extraPaths"], json!([site]));
assert_eq!(cfg.env["VIRTUAL_ENV"], venv.to_string_lossy().as_ref());
let mut cfg = server("pyright");
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
assert_eq!(opts["settings"]["python"]["analysis"]["extraPaths"], json!([site]));
let mut cfg = server("ruff");
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
assert!(cfg.initialization_options.is_none());
}
#[test]
fn without_a_local_python_only_extra_paths_reach_the_server() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(neutral_python_with(tmp.path(), "3.12.7", |_| None), None);
let snapshot = snapshot_in(tmp.path(), None);
let mut cfg = server("basedpyright");
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
assert!(opts.get("python").is_none(), "no pythonPath: the server falls back to its bundled typeshed");
assert_eq!(opts["settings"]["basedpyright"]["analysis"]["extraPaths"].as_array().unwrap().len(), 1);
assert!(!cfg.env.contains_key("VIRTUAL_ENV"));
}
// A real `python3 -m venv`: runs where a Python is installed (the dev container, CI).
#[test]
fn neutral_python_is_an_empty_virtualenv() {
let Some(base) = which("python3") else { return };
let tmp = tempfile::tempdir().unwrap();
let which = |name: &str| (name == "python3").then(|| base.clone());
let python = neutral_python_with(tmp.path(), "0.0.1", which).unwrap();
assert!(python.starts_with(tmp.path().join("neutral").join("default")));
let cfg = std::fs::read_to_string(crate::venv_of(&python).unwrap().join("pyvenv.cfg")).unwrap();
assert!(cfg.contains("include-system-site-packages = false"));
assert_eq!(neutral_python_with(tmp.path(), "0.0.1", which), Some(python), "reused");
}
}
+1
Переглянути файл
@@ -9,6 +9,7 @@ mod angular;
pub mod apply;
pub mod arduino;
mod astro;
pub mod container_python;
mod discover;
mod exec;
pub mod go;
+4
Переглянути файл
@@ -254,6 +254,10 @@ fn module_settings(root: &Path, settings: &Settings) -> Settings {
&mut l.go.gopath,
&mut l.java.jdk,
] {
// `target:<id>` names a run target, not a path.
if value.trim().starts_with(ide_core::settings::PythonSettings::TARGET_PREFIX) {
continue;
}
if let Some(path) = crate::apply::setting_path(root, value) {
*value = path.to_string_lossy().into_owned();
}
+45 -1
Переглянути файл
@@ -21,7 +21,7 @@ fn fake_venv(root: &Path, name: &str) -> std::path::PathBuf {
py
}
fn server(name: &str) -> LanguageServerConfig {
pub(crate) fn server(name: &str) -> LanguageServerConfig {
LanguageServerConfig {
name: name.into(),
command: name.into(),
@@ -42,6 +42,7 @@ fn server(name: &str) -> LanguageServerConfig {
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
display_name: None,
pinned: None,
}
}
@@ -188,6 +189,48 @@ fn pyright_gets_python_path_and_venv_env() {
assert!(cfg.env["PATH"].starts_with(&*tmp.path().join(".venv").join(bin_dir()).to_string_lossy()));
}
#[test]
fn a_run_target_interpreter_hides_local_pythons() {
let tmp = tempfile::tempdir().unwrap();
fake_venv(tmp.path(), ".venv");
let mut s = Settings::default();
s.languages.python.interpreter = "target:web".into();
assert_eq!(python_interpreter(tmp.path(), &s), None, "the project virtualenv is not the interpreter");
assert_eq!(terminal_activation(tmp.path(), &s, "bash"), None);
assert!(!tool_install_environment(tmp.path(), &s).commands.contains_key("python"));
let mut cfg = server("basedpyright");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let opts = cfg.initialization_options.map(|o| serde_json::to_value(o).unwrap());
assert!(opts.and_then(|o| o.pointer("/python/pythonPath").cloned()).is_none());
assert!(!cfg.env.contains_key("VIRTUAL_ENV"));
}
#[test]
fn python_configurations_run_on_the_interpreters_target() {
let tmp = tempfile::tempdir().unwrap();
fake_venv(tmp.path(), ".venv");
let mut s = Settings::default();
s.languages.python.interpreter = "target:web".into();
s.run.default_target = "box".into();
let mut own = run("python", "python3", &["main.py"]);
own.extras.target = Some(String::new());
let mut configs = vec![
run("python", "python3", &["manage.py", "runserver"]),
run("pytest", "python3", &["-m", "pytest"]),
run("npm", "npm", &["run", "dev"]),
own,
];
apply_to_run_configs(&mut configs, tmp.path(), &s);
assert_eq!(configs[0].extras.target.as_deref(), Some("web"));
assert_eq!(configs[0].command, "python3", "the container resolves the interpreter");
assert!(!configs[0].env.contains_key("VIRTUAL_ENV"));
assert_eq!(configs[1].extras.target.as_deref(), Some("web"));
assert_eq!(configs[2].extras.target.as_deref(), Some("box"), "other languages keep the default target");
assert_eq!(configs[3].extras.target.as_deref(), Some(""), "a configuration's own choice wins");
let derived = without_derived(&configs[0], tmp.path(), &s);
assert_eq!(derived.extras.target, None, "the target is derived from the interpreter");
}
#[test]
fn jdtls_vm_args() {
let tmp = tempfile::tempdir().unwrap();
@@ -1387,6 +1430,7 @@ fn typescript_7_runs_its_own_language_server() {
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), native, "{name}");
assert_eq!(cfg.args, ["--lsp", "--stdio"]);
assert_eq!(cfg.display_name.as_deref(), Some("TypeScript 7"));
assert!(cfg.initialization_options.is_none());
}
// Picked as a directory (its `lib`, as for older versions), too.
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff. Show more