feat(php): offer composer install when vendor/ is missing or differs from composer.lock (#1611)

PHP files and composer.json of a Composer project show a banner when vendor/autoload.php is missing or a package of composer.lock is not installed at its locked version (packages-dev only unless the install used --no-dev; config.vendor-dir honoured). Install runs composer install in the Run tool window with the PHP of Settings › PHP first on PATH; Ignore hides it until the project is reopened. Rechecked when composer.json, composer.lock or vendor change; not shown in an untrusted project. A Composer Install action does the same.
This commit is contained in:
l0ng-ai
2026-11-06 02:43:22 +08:00
committed by GitHub
parent 7ad02da722
commit 8689271e7c
9 changed files with 361 additions and 1 deletions
+31
View File
@@ -96,6 +96,33 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) ->
.map_err(join_err)
}
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ComposerInstallState {
pub root: ide_core::IpcPath,
pub reason: ide_sdk::php::ComposerInstall,
}
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
pub async fn sdk_php_composer_install(
host: &Host,
window: &str,
path: PathBuf,
) -> IdeResult<Option<ComposerInstallState>> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(None);
}
let root = project.root.clone();
tokio::task::spawn_blocking(move || {
ide_sdk::php::composer_install(&path, &root)
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
})
.await
.map_err(join_err)
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
@@ -211,6 +238,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
);
d.add(
"sdk_php_composer_install",
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
+70
View File
@@ -52,6 +52,43 @@ pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
}
/// Why `composer install` is due in a Composer project.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum ComposerInstall {
/// No `vendor/autoload.php`.
Missing,
/// A package of `composer.lock` is not installed, or at another version.
Outdated,
}
/// The Composer project `file` belongs to (the nearest `composer.json` at or below `root`), and
/// whether its dependencies need `composer install`.
pub fn composer_install(file: &Path, root: &Path) -> Option<(PathBuf, ComposerInstall)> {
let dir = file.ancestors().filter(|d| d.starts_with(root)).find(|d| d.join("composer.json").is_file())?;
let composer: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).ok()?).ok()?;
let vendor = dir.join(composer.pointer("/config/vendor-dir").and_then(Value::as_str).unwrap_or("vendor"));
if !vendor.join("autoload.php").is_file() {
return Some((dir.to_path_buf(), ComposerInstall::Missing));
}
let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
let lock = read(dir.join("composer.lock"))?;
let installed = read(vendor.join("composer/installed.json")).unwrap_or(Value::Null);
// Composer 2 writes `{"packages": [...], "dev": bool}`, Composer 1 the list itself.
let packages = installed.get("packages").unwrap_or(&installed).as_array().cloned().unwrap_or_default();
let versions: std::collections::HashMap<&str, &str> =
packages.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))).collect();
let dev = installed.get("dev").and_then(Value::as_bool).unwrap_or(true);
let sections: &[&str] = if dev { &["packages", "packages-dev"] } else { &["packages"] };
let outdated = sections
.iter()
.filter_map(|section| lock.get(*section).and_then(Value::as_array))
.flatten()
.filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?)))
.any(|(name, version)| versions.get(name) != Some(&version));
outdated.then(|| (dir.to_path_buf(), ComposerInstall::Outdated))
}
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
/// Intelephense keeps the built-in functions of the version a workspace was first indexed
/// with in that storage, and reuses them after the version changes. Without a version, or
@@ -226,6 +263,39 @@ fn lower_bound(alternative: &str) -> Option<Version> {
mod tests {
use super::*;
#[test]
fn composer_install_is_due_without_vendor_or_when_the_lock_differs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let app = root.join("app");
std::fs::create_dir_all(app.join("src")).unwrap();
let file = app.join("src/Kernel.php");
assert_eq!(composer_install(&file, root), None, "not a Composer project");
std::fs::write(app.join("composer.json"), r#"{"config": {"vendor-dir": "lib"}}"#).unwrap();
assert_eq!(composer_install(&file, root), Some((app.clone(), ComposerInstall::Missing)));
std::fs::create_dir_all(app.join("lib/composer")).unwrap();
std::fs::write(app.join("lib/autoload.php"), "<?php").unwrap();
assert_eq!(composer_install(&file, root), None, "installed, no lock to compare with");
let lock = r#"{"packages": [{"name": "a/a", "version": "v1.2.0"}], "packages-dev": [{"name": "t/t", "version": "10.0.0"}]}"#;
std::fs::write(app.join("composer.lock"), lock).unwrap();
let installed = |packages: &str, dev: bool| {
let json = format!(r#"{{"packages": [{packages}], "dev": {dev}}}"#);
std::fs::write(app.join("lib/composer/installed.json"), json).unwrap();
composer_install(&file, root)
};
let a = r#"{"name": "a/a", "version": "v1.2.0"}"#;
let t = r#"{"name": "t/t", "version": "10.0.0"}"#;
assert_eq!(installed(&format!("{a},{t}"), true), None);
assert_eq!(installed(a, false), None, "`--no-dev` leaves out the dev packages");
assert_eq!(installed(a, true), Some((app.clone(), ComposerInstall::Outdated)), "a dev package is missing");
let old = r#"{"name": "a/a", "version": "v1.1.0"}"#;
assert_eq!(installed(&format!("{old},{t}"), true), Some((app.clone(), ComposerInstall::Outdated)));
// Composer 1 lists the packages at the top level.
std::fs::write(app.join("lib/composer/installed.json"), format!("[{a},{t}]")).unwrap();
assert_eq!(composer_install(&file, root), None);
assert_eq!(composer_install(&root.join("other.php"), root), None, "outside the Composer project");
}
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
+1
View File
@@ -400,6 +400,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
sdk::sdk_python_effective,
sdk::sdk_php_effective,
sdk::sdk_php_language_level,
sdk::sdk_php_composer_install,
sdk::sdk_project_libraries,
sdk::sdk_locate_tools,
sdk::sdk_python_create_venv,
+11
View File
@@ -42,6 +42,17 @@ pub async fn sdk_php_language_level(
ide_host::ops::sdk::sdk_php_language_level(&state.host, window.label(), path).await
}
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
#[tauri::command]
#[specta::specta]
pub async fn sdk_php_composer_install(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
) -> IdeResult<Option<ide_host::ops::sdk::ComposerInstallState>> {
ide_host::ops::sdk::sdk_php_composer_install(&state.host, window.label(), path).await
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
#[tauri::command]
+18
View File
@@ -776,6 +776,11 @@ export const commands = {
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
/** The PHP language level of the project `path` belongs to: the effective one and Auto's. */
sdkPhpLanguageLevel: (path: string) => typedError<PhpLanguageLevels, IdeError>(__TAURI_INVOKE("sdk_php_language_level", { path })),
/** The Composer project `path` belongs to, when its dependencies need `composer install`. */
sdkPhpComposerInstall: (path: string) => typedError<{
root: IpcPath,
reason: ComposerInstall,
} | null, IdeError>(__TAURI_INVOKE("sdk_php_composer_install", { path })),
/**
* SDK / library source folders shown under "External Libraries" in the Project view,
* with the Maven / Gradle dependency jars of the build model.
@@ -2378,6 +2383,19 @@ export type ComposeOp = "start" | "stop" | "restart" |
/** Stops and removes the project's containers and networks. */
"down";
/** Why `composer install` is due in a Composer project. */
export type ComposerInstall =
/** No `vendor/autoload.php`. */
"missing" |
/** A package of `composer.lock` is not installed, or at another version. */
"outdated";
/** The Composer project `path` belongs to, when its dependencies need `composer install`. */
export type ComposerInstallState = {
root: IpcPath,
reason: ComposerInstall,
};
/** The user's answer on an install or consent page. */
export type Consent = {
/**
+75
View File
@@ -0,0 +1,75 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const ipc = vi.hoisted(() => ({
sdkPhpComposerInstall: vi.fn(),
runApplySettings: vi.fn(),
}));
const run = vi.hoisted(() => ({ startRun: vi.fn() }));
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
isTauri: () => true,
commands: ipc,
}));
vi.mock("./store", () => run);
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useEditorBanners } from "@/workbench/contributions";
import { composerInstallConfig, registerComposerInstall } from "./composerInstall";
const ok = <T,>(data: T) => Promise.resolve({ status: "ok" as const, data });
/** The banner hook of the file at `path`, as an open editor renders it. */
const render = (path: string) => {
const { useBanner } = useEditorBanners.getState().banners.get("php.composerInstall")!;
return renderHook(() => useBanner(path));
};
let dispose: () => void;
beforeEach(() => {
useProject.setState({ project: { root: "/p", trusted: true } as never });
ipc.sdkPhpComposerInstall.mockImplementation((path: string) =>
ok(path.startsWith("/p/app/") ? { root: "/p/app", reason: "missing" } : null),
);
ipc.runApplySettings.mockImplementation((configs: RunConfig[]) =>
ok(configs.map((c) => ({ ...c, env: { PATH: "/php/bin" } }))),
);
dispose = registerComposerInstall();
});
afterEach(() => {
cleanup();
dispose();
vi.clearAllMocks();
useProject.setState({ project: null });
});
it("offers composer install above the PHP files and composer.json of a project without vendor/", async () => {
const php = render("/p/app/src/Kernel.php");
await waitFor(() => expect(php.result.current?.text).toBe("Composer dependencies are not installed"));
const json = render("/p/app/composer.json");
await waitFor(() => expect(json.result.current?.actions?.map((a) => a.label)).toEqual(["Install", "Ignore"]));
expect(render("/p/app/README.md").result.current).toBeNull();
expect(ipc.sdkPhpComposerInstall).not.toHaveBeenCalledWith("/p/app/README.md");
act(() => php.result.current!.actions![0]!.run());
await waitFor(() => expect(run.startRun).toHaveBeenCalled());
// Settings apply first, so the configured PHP is on PATH.
expect(ipc.runApplySettings).toHaveBeenCalledWith([composerInstallConfig("/p/app")]);
expect(run.startRun.mock.calls[0]![0]).toMatchObject({ command: "composer", args: ["install"], cwd: "/p/app" });
expect(run.startRun.mock.calls[0]![0].env).toEqual({ PATH: "/php/bin" });
});
it("hides the banner once ignored, and in an untrusted project", async () => {
const php = render("/p/app/src/Kernel.php");
await waitFor(() => expect(php.result.current).not.toBeNull());
act(() => php.result.current!.actions![1]!.run());
expect(php.result.current).toBeNull();
expect(render("/p/app/composer.json").result.current).toBeNull();
cleanup();
dispose();
useProject.setState({ project: { root: "/q", trusted: false } as never });
dispose = registerComposerInstall();
expect(render("/q/index.php").result.current).toBeNull();
expect(ipc.sdkPhpComposerInstall).not.toHaveBeenCalledWith("/q/index.php");
});
+149
View File
@@ -0,0 +1,149 @@
/**
* "Composer dependencies are not installed" above the PHP files and `composer.json` of a Composer
* project without `vendor/`, or whose installed packages differ from `composer.lock`, with Install
* (`composer install` on the configured PHP) and Ignore (until the project is reopened).
*/
import { useEffect } from "react";
import { create } from "zustand";
import { registerActions } from "@/platform/actions";
import { call, commands, isTauri, onIdeEvent, type ComposerInstallState, type RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { maybeEditorService, registerEditorBanner } from "@/workbench/contributions";
import { startRun } from "./store";
interface ComposerState {
/** By file: its Composer project needing an install, `null` when none does. */
byPath: Map<string, ComposerInstallState | null>;
ignored: boolean;
installing: Set<string>;
}
const initial = (): ComposerState => ({ byPath: new Map(), ignored: false, installing: new Set() });
const useComposer = create<ComposerState>(initial);
const requested = new Set<string>();
function isComposerFile(path: string) {
return /\.(php|phtml)$/i.test(path) || path.endsWith("/composer.json");
}
function check(path: string) {
if (requested.has(path) || !isTauri()) return;
requested.add(path);
const root = useProject.getState().project?.root;
void call(commands.sdkPhpComposerInstall(path))
.then((state) => {
if (useProject.getState().project?.root !== root || !requested.has(path)) return;
useComposer.setState((s) => ({ byPath: new Map(s.byPath).set(path, state) }));
})
.catch(() => requested.delete(path));
}
/** Checks every open file again (after `composer.json`, `composer.lock` or the vendor folder changed). */
function recheck() {
const paths = [...useComposer.getState().byPath.keys()];
requested.clear();
for (const path of paths) check(path);
}
export function composerInstallConfig(root: string): RunConfig {
return {
id: `composer:install:${root}`,
name: "composer install",
kind: "composer",
command: "composer",
args: ["install"],
cwd: root,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
};
}
async function install(root: string) {
if (useComposer.getState().installing.has(root)) return;
useComposer.setState((s) => ({ installing: new Set(s.installing).add(root) }));
const done = () =>
useComposer.setState((s) => {
const installing = new Set(s.installing);
installing.delete(root);
return { installing };
});
try {
// Puts the PHP of Settings › PHP first on PATH.
const [config] = await call(commands.runApplySettings([composerInstallConfig(root)]));
await startRun(config ?? composerInstallConfig(root), undefined, undefined, {
onExit: () => {
done();
recheck();
},
});
} catch (e) {
done();
throw e;
}
}
function useComposerBanner(path: string) {
const relevant = isComposerFile(path);
const trusted = useProject((s) => !!s.project?.trusted);
const state = useComposer((s) => (relevant && trusted && !s.ignored ? s.byPath.get(path) : null));
const installing = useComposer((s) => !!state && s.installing.has(state.root));
useEffect(() => {
if (relevant && trusted) check(path);
}, [path, relevant, trusted]);
if (!state || installing) return null;
return {
text:
state.reason === "missing"
? "Composer dependencies are not installed"
: "Installed Composer packages differ from composer.lock",
level: "warning" as const,
actions: [
{ label: "Install", run: () => void install(state.root) },
{ label: "Ignore", run: () => useComposer.setState({ ignored: true }) },
],
};
}
const WATCHED = /(^|\/)(composer\.json|composer\.lock)$|\/vendor(\/|$)/;
function reset() {
requested.clear();
useComposer.setState(initial());
}
export function registerComposerInstall(): () => void {
reset();
const disposers = [
registerEditorBanner({ id: "php.composerInstall", order: 10, useBanner: useComposerBanner }),
registerActions([
{
id: "php.composerInstall",
title: "Composer Install",
category: "PHP",
when: "projectOpen",
run: async ({ arg }) => {
const path = typeof arg === "string" ? arg : (maybeEditorService()?.activePath() ?? "");
const root = useProject.getState().project?.root;
if (!root) return;
const state = path ? await call(commands.sdkPhpComposerInstall(path)).catch(() => null) : null;
await install(state?.root ?? root);
},
},
]),
onIdeEvent("fsChanged", (e) => {
if ([...e.created, ...e.modified, ...e.removed].some((p) => WATCHED.test(p))) recheck();
}),
onIdeEvent("excludedDirsChanged", (e) => {
if (e.dirs.some((d) => WATCHED.test(d))) recheck();
}),
onIdeEvent("projectTrustChanged", () => recheck()),
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root) return;
reset();
}),
];
return () => disposers.forEach((d) => d());
}
+2
View File
@@ -37,6 +37,7 @@ import { RunDialogsOverlay } from "./dialogs";
import { registerExternalTools } from "./externalTools";
import { EditConfigsOverlay } from "./EditConfigsDialog";
import { openImport } from "./importConfigs";
import { registerComposerInstall } from "./composerInstall";
import { registerGoActions } from "./goActions";
import { invalidateGoModules } from "./goModules";
import { invalidatePythonPackages } from "./pythonPackages";
@@ -292,6 +293,7 @@ export function activate(): () => void {
registerRunAnythingProvider(runConfigsRunAnything),
registerBuiltinTypes(),
registerGoActions(),
registerComposerInstall(),
registerOverlay({ id: "run.chooser", component: ConfigChooserOverlay }),
registerOverlay({ id: "run.editConfigs", component: EditConfigsOverlay }),
registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }),
+4 -1
View File
@@ -6296,5 +6296,8 @@
"Connect a device over USB, or start an emulator. Logcat uses the Android SDK's adb.": "通过 USB 连接设备,或启动模拟器。Logcat 使用 Android SDK 的 adb。",
"No lines match the filter": "没有符合过滤条件的日志行",
"Streaming": "正在接收",
"Idle": "空闲"
"Idle": "空闲",
"Composer dependencies are not installed": "未安装 Composer 依赖",
"Installed Composer packages differ from composer.lock": "已安装的 Composer 包与 composer.lock 不一致",
"Composer Install": "安装 Composer 依赖"
}