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

...
Автор SHA1 Повідомлення Дата
l0ng-ai 4f35ed2dd3 feat(templates): New ▸ C# Class with the project's namespace
Class, Interface, Struct, Enum and Record. The namespace is the nearest .csproj's
RootNamespace (else its file name) plus the folders below it. Projects on C# 10 or later
(.NET 6+, or LangVersion 10/latest) get a file-scoped namespace; .NET Framework, Unity,
.NET Standard and .NET Core projects get a namespace block.

Fixes #3190
2026-11-14 01:17:12 +08:00
7 змінених файлів з 171 додано та 5 видалено

No files matched your search

+41
Переглянути файл
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import {
blockScopedNamespace,
caretAfterReformat,
csprojFileScoped,
csprojRootNamespace,
inferCsharpNamespace,
expandIncludes,
fragmentResolver,
composerPsr4,
@@ -180,6 +184,43 @@ describe("PHP namespaces", () => {
});
});
describe("C# namespaces", () => {
it("come from the project's root namespace and the folders below the .csproj", () => {
expect(csprojRootNamespace('<Project Sdk="Microsoft.NET.Sdk"></Project>', "My.App.csproj")).toBe("My.App");
expect(csprojRootNamespace("<RootNamespace>Acme.Shop</RootNamespace>", "Web.csproj")).toBe("Acme.Shop");
expect(csprojRootNamespace("", "my-app.csproj")).toBe("my_app");
expect(inferCsharpNamespace("/Services/Billing", "Acme.Shop")).toBe("Acme.Shop.Services.Billing");
expect(inferCsharpNamespace("", "Acme.Shop")).toBe("Acme.Shop");
expect(inferCsharpNamespace("/2024-data", "App")).toBe("App._2024_data");
});
it("are file-scoped from C# 10 and blocks before it (.NET Framework and Unity projects)", () => {
const sdk = (tf: string) => `<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup>${tf}</PropertyGroup></Project>`;
expect(csprojFileScoped(sdk("<TargetFramework>net8.0</TargetFramework>"))).toBe(true);
expect(csprojFileScoped(sdk("<TargetFrameworks>net6.0;net8.0</TargetFrameworks>"))).toBe(true);
expect(csprojFileScoped(sdk("<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>"))).toBe(false);
expect(csprojFileScoped(sdk("<TargetFramework>net48</TargetFramework>"))).toBe(false);
expect(csprojFileScoped(sdk("<TargetFramework>net5.0</TargetFramework>"))).toBe(false);
expect(
csprojFileScoped(sdk("<TargetFramework>netstandard2.1</TargetFramework><LangVersion>latest</LangVersion>")),
).toBe(true);
expect(
csprojFileScoped("<TargetFrameworkVersion>v4.7.1</TargetFrameworkVersion><LangVersion>9.0</LangVersion>"),
).toBe(false);
});
it("render C# classes with a file-scoped namespace, a block, or none", () => {
const cls = tpl("csharp").kinds[0]!.body;
const vars = { name: "OrderService", fileName: "OrderService.cs", packageName: "Acme.Shop" };
expect(renderFileTemplate(cls, vars).text).toBe("namespace Acme.Shop;\n\npublic class OrderService\n{\n \n}\n");
expect(renderFileTemplate(blockScopedNamespace(cls), vars).text).toBe(
"namespace Acme.Shop\n{\n public class OrderService\n {\n \n }\n}\n",
);
expect(renderFileTemplate(cls, { name: "Foo", fileName: "Foo.cs" }).text).toBe("public class Foo\n{\n \n}\n");
expect(tpl("csharp").kinds.map((k) => k.label)).toEqual(["Class", "Interface", "Struct", "Enum", "Record"]);
});
});
describe("rendering", () => {
it("substitutes variables and places the caret", () => {
const kind = tpl("java").kinds[0]!;
+16
Переглянути файл
@@ -53,6 +53,22 @@ describe("plugin package inference uses built-in rules", () => {
);
expect(await packageFor({ ...plugin("java"), fileName: ".dockerignore" }, "/p/src/main/java/com/acme")).toBe("");
});
it("uses the nearest .csproj for C#", async () => {
useProject.setState({ project: { root: "/p", name: "p" } as never });
const csproj = { kind: "file", name: "Shop.csproj", path: "/p/src/Shop/Shop.csproj" };
const readDir = vi
.mocked(commands.fsReadDir)
.mockImplementation((async (dir: string) => (dir === "/p/src/Shop" ? [csproj] : [])) as never);
mocks.read.mockResolvedValue(
"<Project><PropertyGroup><RootNamespace>Acme.Shop</RootNamespace></PropertyGroup></Project>",
);
try {
expect(await packageFor(plugin("cs"), "/p/src/Shop/Orders/Api")).toBe("Acme.Shop.Orders.Api");
expect(await packageFor(plugin("cs"), "/p/tools")).toBe("");
} finally {
readDir.mockReset();
}
});
it("uses an existing Go package", async () => {
useProject.setState({ project: { root: "/p", name: "p" } as never });
mocks.read.mockResolvedValue("package service\n");
+5 -1
Переглянути файл
@@ -302,7 +302,11 @@ function NameStep({
{error ?? (
<>
{where}
{packageStyle && pkg ? (packageStyle === "php" ? ` · namespace ${pkg}` : ` · package ${pkg}`) : ""}
{packageStyle && pkg
? packageStyle === "php" || packageStyle === "csharp"
? ` · namespace ${pkg}`
: ` · package ${pkg}`
: ""}
</>
)}
</div>
+21
Переглянути файл
@@ -294,6 +294,27 @@ export const BUILTIN_FILE_TEMPLATES: readonly FileTemplate[] = [
},
],
},
{
id: "csharp",
name: "C# Class",
icon: "class",
extension: "cs",
nameStyle: "pascal",
packageStyle: "csharp",
kinds: (
[
["class", "Class", "class"],
["interface", "Interface", "interface"],
["struct", "Struct", "struct"],
["enum", "Enum", "enum"],
["record", "Record", "record"],
] as const
).map(([id, label, keyword]) => ({
id,
label,
body: `namespace $PACKAGE$;\n\n#parse("File Header")\npublic ${keyword} $NAME$\n{\n $END$\n}\n`,
})),
},
{
id: "kotlin",
name: "Kotlin Class/File",
+33 -2
Переглянути файл
@@ -10,8 +10,12 @@ import { activeEditor, editorsFor, type TextEditorHandle } from "@/editor/api";
import { editorService, maybeEditorService, notifications, resolveFileEditor } from "@/workbench/contributions";
import {
GO_PACKAGE_RE,
blockScopedNamespace,
caretAfterReformat,
composerPsr4,
csprojFileScoped,
csprojRootNamespace,
inferCsharpNamespace,
inferGoPackage,
inferJvmPackage,
inferPhpNamespace,
@@ -68,6 +72,26 @@ async function composerNamespace(directory: string, root: string | null): Promis
}
}
/** C# namespace from the nearest .csproj between `directory` and the project root. */
async function csharpNamespace(directory: string, root: string | null): Promise<{ name: string; fileScoped: boolean }> {
const none = { name: "", fileScoped: true };
if (!isTauri()) return none;
const start = directory.replace(/\/+$/, "");
const top = root?.replace(/\/+$/, "") ?? null;
for (let dir = start; ; dir = dirname(dir)) {
const csproj = await call(commands.fsReadDir(dir)).then(
(entries) => entries.find((e) => e.kind === "file" && /\.csproj$/i.test(e.name)),
() => undefined,
);
if (csproj) {
const text = await call(commands.fsReadText(csproj.path)).catch(() => "");
const rootNamespace = csprojRootNamespace(text, csproj.name);
return { name: inferCsharpNamespace(start.slice(dir.length), rootNamespace), fileScoped: csprojFileScoped(text) };
}
if (!top || dir === top || !dir.startsWith(`${top}/`)) return none;
}
}
export function packageStyleFor(t: FileTemplate): FileTemplate["packageStyle"] {
const dot = t.fileName?.lastIndexOf(".") ?? -1;
const extension = t.fileName === undefined ? t.extension : dot > 0 ? t.fileName.slice(dot + 1) : "";
@@ -86,6 +110,7 @@ export async function packageFor(t: FileTemplate, directory: string): Promise<st
if (style === "jvm") return inferJvmPackage(directory, root, packageRoots());
if (style === "go") return inferGoPackage(directory, root, await siblingGoPackage(directory));
if (style === "php") return composerNamespace(directory, root);
if (style === "csharp") return (await csharpNamespace(directory, root)).name;
return "";
}
@@ -143,9 +168,15 @@ export async function createFromTemplate(
const plan = planFile(withKind(t, kind), directory, input);
if (!plan) throw new Error(tx("Invalid file name"));
const project = useProject.getState().project;
const packageName = await packageFor(withKind(t, kind), plan.directory);
let body = kind.body;
let packageName: string;
if (packageStyleFor(withKind(t, kind)) === "csharp") {
const ns = await csharpNamespace(plan.directory, project?.root ?? null);
packageName = ns.name;
if (ns.name && !ns.fileScoped) body = blockScopedNamespace(body);
} else packageName = await packageFor(withKind(t, kind), plan.directory);
const { text, caret } = renderFileTemplate(
kind.body,
body,
{
variables,
name: plan.name,
+50
Переглянути файл
@@ -254,6 +254,56 @@ export function inferPhpNamespace(directory: string, psr4: ReadonlyArray<readonl
return parts.every((p) => /^[A-Za-z_\x80-\uffff][\w\x80-\uffff]*$/.test(p)) ? parts.join("\\") : "";
}
/** A folder or project name as a C# identifier: other characters become `_`, as Rider does. */
function csharpIdentifier(s: string): string {
const id = s.replace(/[^\p{L}\p{N}_]/gu, "_");
return /^\p{N}/u.test(id) ? `_${id}` : id;
}
/** A .csproj's `<RootNamespace>`, else its file name (`My.App.csproj` → `My.App`). */
export function csprojRootNamespace(text: string, fileName: string): string {
const explicit = /<RootNamespace>\s*([^<]*?)\s*<\/RootNamespace>/i.exec(text)?.[1];
const base = explicit || fileName.replace(/\.csproj$/i, "");
return base.split(".").filter(Boolean).map(csharpIdentifier).join(".");
}
/**
* Whether a .csproj's C# version has file-scoped namespaces (C# 10): its `<LangVersion>`,
* else every target framework is .NET 6 or later. .NET Framework (Unity), .NET Standard and
* .NET Core projects stay on C# 9 or earlier.
*/
export function csprojFileScoped(text: string): boolean {
const lang = /<LangVersion>\s*([^<]*?)\s*<\/LangVersion>/i.exec(text)?.[1]?.toLowerCase();
if (lang && /^(latest|latestmajor|preview)$/.test(lang)) return true;
if (lang && /^\d+(\.\d+)?$/.test(lang)) return Number(lang) >= 10;
if (/<TargetFrameworkVersion>/i.test(text)) return false;
const frameworks = /<TargetFrameworks?>\s*([^<]*?)\s*<\/TargetFrameworks?>/i.exec(text)?.[1];
if (!frameworks) return true;
return frameworks
.split(";")
.map((f) => f.trim())
.filter(Boolean)
.every((f) => Number(/^net(\d+)\.\d/.exec(f)?.[1] ?? 0) >= 6);
}
/** The namespace of a folder `relDir` below the .csproj: the root namespace plus each folder. */
export function inferCsharpNamespace(relDir: string, rootNamespace: string): string {
const folders = relDir.split("/").filter(Boolean).map(csharpIdentifier);
return [...rootNamespace.split(".").filter(Boolean), ...folders].join(".");
}
/** A body with `namespace $PACKAGE$;` rewritten as a namespace block, for C# before 10. */
export function blockScopedNamespace(body: string): string {
const m = /^namespace \$PACKAGE\$;\n\n?/m.exec(body);
if (!m) return body;
const rest = body.slice(m.index + m[0].length).replace(/\n+$/, "");
const indented = rest
.split("\n")
.map((l) => (l ? ` ${l}` : l))
.join("\n");
return `${body.slice(0, m.index)}namespace $PACKAGE$\n{\n${indented}\n}\n`;
}
export interface FileTemplateVars {
name: string;
fileName: string;
+5 -2
Переглянути файл
@@ -16,8 +16,11 @@ export interface FileTemplate {
/** Without the dot; empty for "File…" (the name carries the extension). */
extension: string;
nameStyle: NameStyle;
/** Package inference: `jvm` (Java/Kotlin source roots) or `go` (directory). */
packageStyle?: "jvm" | "go" | "php";
/**
* Package inference: `jvm` (Java/Kotlin source roots), `go` (directory), `php` (composer.json
* PSR-4) or `csharp` (the nearest .csproj's root namespace and the folders below it).
*/
packageStyle?: "jvm" | "go" | "php" | "csharp";
/** Dotted names (`com.acme.Foo`) create package directories. */
dottedPackages?: boolean;
kinds: FileTemplateKind[];