Compare commits

...
Author SHA1 Message Date
l0ng-ai ff3d6213a4 feat(php): New › PHP File and PHP Class with the namespace from composer.json
The New menu had no PHP entries. PHP File writes <?php and the caret; PHP Class
offers Class, Interface, Trait, Enum and PHPUnit Test, each with
namespace $PACKAGE$; taken from the nearest composer.json's autoload /
autoload-dev PSR-4 map (longest mapped folder wins, folders below it become
namespace segments; no namespace line outside every mapping). The New dialog
shows 'namespace …', and Create New Test… on a .php file opens PHP Class with
PHPUnit Test.
2026-11-06 01:40:30 +08:00
6 changed files with 172 additions and 2 deletions

No files matched your search

@@ -3,8 +3,10 @@ import {
caretAfterReformat,
expandIncludes,
fragmentResolver,
composerPsr4,
inferGoPackage,
inferJvmPackage,
inferPhpNamespace,
planFile,
reformatsOnCreate,
renderFileTemplate,
@@ -92,6 +94,58 @@ describe("file naming", () => {
});
});
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("<?php\n\nnamespace App\\Http;\n\nclass UserController\n{\n \n}\n");
const bare = renderFileTemplate(kinds[0]!.body, { name: "Foo", fileName: "Foo.php" });
expect(bare.text).toBe("<?php\n\nclass Foo\n{\n \n}\n");
const test = renderFileTemplate(kinds.find((k) => k.id === "test")!.body, {
name: "MailerTest",
fileName: "MailerTest.php",
packageName: "App\\Tests\\Service",
});
expect(test.text).toBe(
"<?php\n\nnamespace App\\Tests\\Service;\n\nuse PHPUnit\\Framework\\TestCase;\n\nfinal class MailerTest extends TestCase\n{\n public function testSomething(): void\n {\n \n }\n}\n",
);
expect(renderFileTemplate(tpl("php").kinds[0]!.body, { name: "bootstrap", fileName: "bootstrap.php" }).text).toBe(
"<?php\n\n\n",
);
});
});
describe("rendering", () => {
it("substitutes variables and places the caret", () => {
const kind = tpl("java").kinds[0]!;
@@ -233,7 +233,11 @@ function NameStep({
{error ?? (
<>
{where}
{template.packageStyle && pkg ? ` · package ${pkg}` : ""}
{template.packageStyle && pkg
? template.packageStyle === "php"
? ` · namespace ${pkg}`
: ` · package ${pkg}`
: ""}
</>
)}
</div>
+48
View File
@@ -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: "<?php\n\n$END$\n" }],
},
{
id: "php-class",
name: "PHP Class",
icon: "class",
extension: "php",
nameStyle: "pascal",
packageStyle: "php",
kinds: [
{
id: "class",
label: "Class",
body: '<?php\n\nnamespace $PACKAGE$;\n\n#parse("File Header")\nclass $NAME$\n{\n $END$\n}\n',
},
{
id: "interface",
label: "Interface",
body: '<?php\n\nnamespace $PACKAGE$;\n\n#parse("File Header")\ninterface $NAME$\n{\n $END$\n}\n',
},
{
id: "trait",
label: "Trait",
body: '<?php\n\nnamespace $PACKAGE$;\n\n#parse("File Header")\ntrait $NAME$\n{\n $END$\n}\n',
},
{
id: "enum",
label: "Enum",
body: '<?php\n\nnamespace $PACKAGE$;\n\n#parse("File Header")\nenum $NAME$\n{\n $END$\n}\n',
},
{
id: "test",
label: "PHPUnit Test",
body: '<?php\n\nnamespace $PACKAGE$;\n\nuse PHPUnit\\Framework\\TestCase;\n\n#parse("File Header")\nfinal class $NAME$ extends TestCase\n{\n #parse("PHPUnit Test Method")\n}\n',
},
],
},
{
id: "java",
name: "Java Class",
+21
View File
@@ -10,8 +10,10 @@ import { editorService, maybeEditorService, notifications } from "@/workbench/co
import {
GO_PACKAGE_RE,
caretAfterReformat,
composerPsr4,
inferGoPackage,
inferJvmPackage,
inferPhpNamespace,
planFile,
reformatsOnCreate,
renderFileTemplate,
@@ -46,10 +48,29 @@ function packageRoots(): string[] {
.map((r) => 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<string> {
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<string> {
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 "";
}
+42 -1
View File
@@ -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<string, { "psr-4"?: Record<string, unknown> } | 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<readonly [string, string]>): 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;
+2
View File
@@ -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 文件",