mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 17:37:06 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f35ed2dd3 |
No files matched your search
@@ -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]!;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user