From ff3d6213a40e7b87855b993bf9d19089f6c00757 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:40:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(php):=20New=20=E2=80=BA=20PHP=20File=20and?= =?UTF-8?q?=20PHP=20Class=20with=20the=20namespace=20from=20composer.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The New menu had no PHP entries. PHP File writes { }); }); +describe("PHP namespaces", () => { + const psr4 = composerPsr4( + JSON.stringify({ + autoload: { "psr-4": { "App\\": "src/", "Legacy\\": ["src/legacy/", "lib"] } }, + "autoload-dev": { "psr-4": { "App\\Tests\\": "./tests" } }, + }), + ); + + it("reads autoload and autoload-dev PSR-4 maps", () => { + expect(psr4).toEqual([ + ["App\\", "src"], + ["Legacy\\", "src/legacy"], + ["Legacy\\", "lib"], + ["App\\Tests\\", "tests"], + ]); + expect(composerPsr4("{ not json")).toEqual([]); + }); + + it("uses the longest mapped folder and adds the folders below it", () => { + expect(inferPhpNamespace("/src/Http", psr4)).toBe("App\\Http"); + expect(inferPhpNamespace("/src", psr4)).toBe("App"); + expect(inferPhpNamespace("/src/legacy/Billing", psr4)).toBe("Legacy\\Billing"); + expect(inferPhpNamespace("/tests/Service", psr4)).toBe("App\\Tests\\Service"); + expect(inferPhpNamespace("/config", psr4)).toBe(""); + expect(inferPhpNamespace("/src/my-dir", psr4)).toBe(""); + expect(inferPhpNamespace("/x", [["", ""]])).toBe("x"); + }); + + it("writes the namespace into PHP classes, and leaves it out without one", () => { + const kinds = tpl("php-class").kinds; + const cls = renderFileTemplate(kinds[0]!.body, { + name: "UserController", + fileName: "UserController.php", + packageName: "App\\Http", + }); + expect(cls.text).toBe(" k.id === "test")!.body, { + name: "MailerTest", + fileName: "MailerTest.php", + packageName: "App\\Tests\\Service", + }); + expect(test.text).toBe( + " { it("substitutes variables and places the caret", () => { const kind = tpl("java").kinds[0]!; diff --git a/src/features/templates/files/NewFileDialog.tsx b/src/features/templates/files/NewFileDialog.tsx index 60077ff1d..f00efbacb 100644 --- a/src/features/templates/files/NewFileDialog.tsx +++ b/src/features/templates/files/NewFileDialog.tsx @@ -233,7 +233,11 @@ function NameStep({ {error ?? ( <> {where} - {template.packageStyle && pkg ? ` · package ${pkg}` : ""} + {template.packageStyle && pkg + ? template.packageStyle === "php" + ? ` · namespace ${pkg}` + : ` · package ${pkg}` + : ""} )} diff --git a/src/features/templates/files/builtins.ts b/src/features/templates/files/builtins.ts index 6e0083116..c869a8137 100644 --- a/src/features/templates/files/builtins.ts +++ b/src/features/templates/files/builtins.ts @@ -31,6 +31,11 @@ export const BUILTIN_CODE_TEMPLATES: readonly TemplateFragment[] = [ description: "TypeScript File › Test", body: 'it("works", () => {\n $END$\n});\n', }, + { + name: "PHPUnit Test Method", + description: "PHP Class › PHPUnit Test", + body: "public function testSomething(): void\n{\n $END$\n}\n", + }, { name: "pytest Test Function", description: "Python File › Test (pytest)", @@ -149,6 +154,49 @@ export const BUILTIN_FILE_TEMPLATES: readonly FileTemplate[] = [ defaultName: "go", kinds: [{ id: "module", label: "Module", body: "module $PROJECT_NAME$\n\ngo 1.20\n" }], }, + { + id: "php", + name: "PHP File", + icon: "file-php", + extension: "php", + nameStyle: "asis", + kinds: [{ id: "file", label: "File", body: " r.path); } +/** The namespace from the nearest composer.json between `directory` and the project root (PSR-4). */ +async function composerNamespace(directory: string, root: string | null): Promise { + if (!isTauri()) return ""; + let dir = directory.replace(/\/+$/, ""); + const top = root?.replace(/\/+$/, "") ?? null; + for (;;) { + let text: string | null = null; + try { + text = await call(commands.fsReadText(`${dir}/composer.json`)); + } catch { + /* no composer.json here */ + } + if (text !== null) return inferPhpNamespace(directory.slice(dir.length), composerPsr4(text)); + if (!top || dir === top || !dir.startsWith(`${top}/`)) return ""; + dir = dirname(dir); + } +} + export async function packageFor(t: FileTemplate, directory: string): Promise { const root = useProject.getState().project?.root ?? null; if (t.packageStyle === "jvm") return inferJvmPackage(directory, root, packageRoots()); if (t.packageStyle === "go") return inferGoPackage(directory, root, await siblingGoPackage(directory)); + if (t.packageStyle === "php") return composerNamespace(directory, root); return ""; } diff --git a/src/features/templates/files/model.ts b/src/features/templates/files/model.ts index 591031b04..a5ae55110 100644 --- a/src/features/templates/files/model.ts +++ b/src/features/templates/files/model.ts @@ -32,7 +32,7 @@ export interface FileTemplate { extension: string; nameStyle: NameStyle; /** Package inference: `jvm` (Java/Kotlin source roots) or `go` (directory). */ - packageStyle?: "jvm" | "go"; + packageStyle?: "jvm" | "go" | "php"; /** Dotted names (`com.acme.Foo`) create package directories. */ dottedPackages?: boolean; kinds: FileTemplateKind[]; @@ -223,6 +223,47 @@ export function inferGoPackage(directory: string, projectRoot: string | null, si export const GO_PACKAGE_RE = /^\s*package\s+([A-Za-z_]\w*)/m; +/** `autoload.psr-4` and `autoload-dev.psr-4` of a composer.json: `[namespace prefix, folder]` (folder relative to it). */ +export function composerPsr4(json: string): Array<[string, string]> { + let data: unknown; + try { + data = JSON.parse(json); + } catch { + return []; + } + const out: Array<[string, string]> = []; + for (const section of ["autoload", "autoload-dev"]) { + const map = (data as Record } | undefined> | null)?.[section]?.[ + "psr-4" + ]; + if (!map || typeof map !== "object") continue; + for (const [prefix, value] of Object.entries(map)) { + for (const dir of Array.isArray(value) ? value : [value]) + if (typeof dir === "string") + out.push([prefix, dir.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")]); + } + } + return out; +} + +/** + * PHP namespace for `directory` (relative to the composer.json folder): the PSR-4 prefix of the + * longest mapped folder that contains it, then the folders below it (`App\\` + `src/` → `src/Http` + * gives `App\Http`). Outside every mapping: no namespace. + */ +export function inferPhpNamespace(directory: string, psr4: ReadonlyArray): string { + const rel = directory.split("/").filter(Boolean); + let best: { prefix: string; depth: number } | null = null; + for (const [prefix, dir] of psr4) { + const segs = dir.split("/").filter((s) => s && s !== "."); + if (segs.length > rel.length || segs.some((s, i) => s !== rel[i])) continue; + if (!best || segs.length > best.depth) best = { prefix, depth: segs.length }; + } + if (!best) return ""; + const parts = [...best.prefix.split("\\").filter(Boolean), ...rel.slice(best.depth)]; + return parts.every((p) => /^[A-Za-z_\x80-\uffff][\w\x80-\uffff]*$/.test(p)) ? parts.join("\\") : ""; +} + export interface FileTemplateVars { name: string; fileName: string; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 1a53b6687..e62b76bb2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1153,6 +1153,7 @@ "Issue navigation links": "问题导航链接", "Italic": "斜体", "JUnit 5 Test": "JUnit 5 测试", + "PHPUnit Test": "PHPUnit 测试", "JUnit test": "JUnit 测试", "JVM options": "JVM 选项", "Jump outside closing bracket/quote with Tab": "使用 Tab 跳出右括号/引号", @@ -3334,6 +3335,7 @@ "Rust File › Test module": "Rust 文件 › 测试模块", "TypeScript File › Test": "TypeScript 文件 › 测试", "Python File › Test (pytest)": "Python 文件 › 测试 (pytest)", + "PHP Class › PHPUnit Test": "PHP 类 › PHPUnit 测试", "CSS File": "CSS 文件", "SCSS File": "SCSS 文件", "Sass File": "Sass 文件",