Repository: alloy-framework/alloy
Pull request #27 (2024-09-19 16:37:01)

Improved parameters and class constructors
Added Parameter so that they have symbols and can be resolved by refkey. Added ClassConstructor to emit class constructors. Name name prop for Class and ClassMethod required. Fixed scope for ClassMethod so its params have the correct scope. Added field for .NET TFM when generating the csproj file. Naming for type parameters.
Renamed some scope names to follow C# language spec.

pkg-pr-new[bot]: 
[Open in Stackblitz](https://pkg.pr.new/template/92637bdb-0c43-4689-8380-abeb4e236e22)


<details><summary><b>@alloy-js/babel-plugin-alloy</b></summary><p>

```
pnpm add https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin-alloy@27
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-plugin-jsx-dom-expressions</b></summary><p>

```
pnpm add https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin-jsx-dom-expressions@27
```

</p></details>
      
    

<details><summary><b>babel-preset-alloy</b></summary><p>

```
pnpm add https://pkg.pr.new/alloy-framework/alloy/babel-preset-alloy@27
```

</p></details>
      
    

<details><summary><b>@alloy-js/core</b></summary><p>

```
pnpm add https://pkg.pr.new/alloy-framework/alloy/@alloy-js/core@27
```

</p></details>
      
    

<details><summary><b>@alloy-js/csharp</b></summary><p>

```
pnpm add https://pkg.pr.new/alloy-framework/alloy/@alloy-js/csharp@27
```

</p></details>
      
    

<details><summary><b>@alloy-js/java</b></summary><p>

```
pnpm add https://pkg.pr.new/alloy-framework/alloy/@alloy-js/java@27
```

</p></details>
      
    

<details><summary><b>@alloy-js/prettier-plugin-alloy</b></summary><p>

```
pnpm add https://pkg.pr.new/alloy-framework/alloy/@alloy-js/prettier-plugin-alloy@27
```

</p></details>
      
    

<details><summary><b>@alloy-js/typescript</b></summary><p>

```
pnpm add https://pkg.pr.new/alloy-framework/alloy/@alloy-js/typescript@27
```

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/30388950406"><code>1f86564</code></a>_


Patch:
diff --git a/packages/csharp/src/components/Class.tsx b/packages/csharp/src/components/Class.tsx
index 847f7e7..9884bfd 100644
--- a/packages/csharp/src/components/Class.tsx
+++ b/packages/csharp/src/components/Class.tsx
@@ -8,11 +8,12 @@ import {
 import { useCSharpNamePolicy } from "../name-policy.js";
 import { CSharpOutputSymbol } from "../symbols/csharp-output-symbol.js";
 import { createCSharpMemberScope, useCSharpScope } from "../symbols/scopes.js";
-import { Name } from "./Name.jsx";
-import { Parameters } from "./Parameters.jsx";
+import { Name } from "./Name.js";
+import { ParameterProps, Parameters } from "./Parameters.js";
 
 // properties for creating a class
 export interface ClassProps extends Omit<core.DeclarationProps, "nameKind"> {
+  name: string;
   accessModifier?: AccessModifier;
   typeParameters?: Record<string, core.Refkey>;
 }
@@ -36,14 +37,16 @@ export function Class(props: ClassProps) {
     scope.binder,
     scope,
     thisClassSymbol,
-    "class",
+    "class-decl",
   );
 
   let typeParams: string | undefined;
   if (props.typeParameters) {
     const typeParamNames = new Array<string>();
     for (const entry of Object.entries(props.typeParameters)) {
-      typeParamNames.push(entry[0]);
+      typeParamNames.push(
+        useCSharpNamePolicy().getName(entry[0], "type-parameter"),
+      );
       // create a symbol for each type param so its
       // refkey resolves to the type param's name
       scope.binder.createSymbol<CSharpOutputSymbol>({
@@ -68,11 +71,63 @@ export function Class(props: ClassProps) {
     </core.Declaration>;
 }
 
+export interface ClassConstructorProps {
+  accessModifier?: AccessModifier;
+  parameters?: Array<ParameterProps>;
+  refkey?: core.Refkey;
+  symbol?: core.OutputSymbol;
+  children?: core.Children;
+}
+
+// a C# class constructor
+export function ClassConstructor(props: ClassConstructorProps) {
+  const scope = useCSharpScope();
+  if (scope.kind !== "member" || scope.name !== "class-decl") {
+    throw new Error(
+      "can't define a class constructor outside of a class-decl scope",
+    );
+  }
+
+  // fetch the class name from the scope
+  const name = useCSharpNamePolicy().getName(scope.owner.name, "class-method");
+  const ctorSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
+    name: name,
+    scope,
+    refkey: props.refkey ?? core.refkey(name),
+  });
+
+  // scope for ctor declaration
+  const ctorDeclScope = createCSharpMemberScope(
+    scope.binder,
+    scope,
+    ctorSymbol,
+    "constructor-decl",
+  );
+
+  const accessModifier = getAccessModifier(props.accessModifier);
+  const params = props.parameters ?
+    <Parameters parameters={props.parameters} />
+  : "";
+
+  // note that scope wraps the ctor decl so that the params get the correct scope
+  return <core.Declaration symbol={ctorSymbol}>
+    <core.Scope value={ctorDeclScope}>
+      {accessModifier}<Name />({params}){!props.children && " {}"}{props.children &&
+        <>
+          {"\n{"}
+            {props.children}
+          {"}"}
+        </>
+        }
+      </core.Scope>
+    </core.Declaration>;
+}
+
 // properties for creating a class member
 export interface ClassMemberProps {
-  accessModifier?: AccessModifier;
   name: string;
   type: core.Children;
+  accessModifier?: AccessModifier;
   refkey?: core.Refkey;
 }
 
@@ -80,8 +135,10 @@ export interface ClassMemberProps {
 export function ClassMember(props: ClassMemberProps) {
   const name = useCSharpNamePolicy().getName(props.name, "class-member");
   const scope = useCSharpScope();
-  if (scope.kind !== "member" || scope.name !== "class") {
-    throw new Error("can't define a class member outside of a class scope");
+  if (scope.kind !== "member" || scope.name !== "class-decl") {
+    throw new Error(
+      "can't define a class member outside of a class-decl scope",
+    );
   }
 
   const memberSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
@@ -98,9 +155,10 @@ export function ClassMember(props: ClassMemberProps) {
 // properties for creating a method
 export interface ClassMethodProps
   extends Omit<core.DeclarationProps, "nameKind"> {
+  name: string;
   accessModifier?: AccessModifier;
   methodModifier?: MethodModifier;
-  parameters?: Record<string, core.Children>;
+  parameters?: Array<ParameterProps>;
   returns?: core.Children;
 }
 
@@ -108,7 +166,7 @@ export interface ClassMethodProps
 export function ClassMethod(props: ClassMethodProps) {
   const name = useCSharpNamePolicy().getName(props.name!, "class-method");
   const scope = useCSharpScope();
-  if (scope.kind !== "member" || scope.name !== "class") {
+  if (scope.kind !== "member" || scope.name !== "class-decl") {
     throw new Error("can't define a class method outside of a class scope");
   }
 
@@ -118,15 +176,12 @@ export function ClassMethod(props: ClassMethodProps) {
     refkey: props.refkey ?? core.refkey(props.name),
   });
 
-  // this creates a new scope for the class method.
-  // members will automatically "inherit" this scope so
-  // that refkeys to them will produce the fully-qualified
-  // name e.g. Foo.Bar.
+  // scope for method declaration
   const methodScope = createCSharpMemberScope(
     scope.binder,
     scope,
     methodSymbol,
-    "method",
+    "method-decl",
   );
 
   const accessModifier = getAccessModifier(props.accessModifier);
@@ -136,15 +191,16 @@ export function ClassMethod(props: ClassMethodProps) {
   : "";
   const returns = props.returns ?? "void";
 
+  // note that scope wraps the method decl so that the params get the correct scope
   return <core.Declaration symbol={methodSymbol}>
+    <core.Scope value={methodScope}>
       {accessModifier}{methodModifier}{returns} <Name />({params}){!props.children && " {}"}{props.children &&
         <>
           {"\n{"}
-            <core.Scope value={methodScope}>
-              {props.children}
-            </core.Scope>
+            {props.children}
           {"}"}
         </>
         }
+      </core.Scope>
     </core.Declaration>;
 }
diff --git a/packages/csharp/src/components/Enum.tsx b/packages/csharp/src/components/Enum.tsx
index 5059604..360821e 100644
--- a/packages/csharp/src/components/Enum.tsx
+++ b/packages/csharp/src/components/Enum.tsx
@@ -7,6 +7,7 @@ import { Name } from "./Name.jsx";
 
 // properties for creating an enum
 export interface EnumProps extends Omit<core.DeclarationProps, "nameKind"> {
+  name: string;
   accessModifier?: AccessModifier;
 }
 
@@ -29,7 +30,7 @@ export function Enum(props: EnumProps) {
     scope.binder,
     scope,
     thisEnumSymbol,
-    "enum",
+    "enum-decl",
   );
 
   return <core.Declaration symbol={thisEnumSymbol}>
@@ -54,12 +55,20 @@ export interface EnumMemberProps {
 // a member within a C# enum
 export function EnumMember(props: EnumMemberProps) {
   const scope = useCSharpScope();
-  if (scope.kind === "member" && scope.name !== "enum") {
-    throw new Error("can't define an enum member outside of an enum scope");
+  if (scope.kind === "member" && scope.name !== "enum-decl") {
+    throw new Error(
+      "can't define an enum member outside of an enum-decl scope",
+    );
   }
+
   const name = useCSharpNamePolicy().getName(props.name, "enum-member");
+  const thisEnumValueSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
+    name: name,
+    scope,
+    refkey: props.refkey ?? core.refkey(props.name),
+  });
 
-  return <core.Declaration name={name} refkey={props.refkey}>
+  return <core.Declaration symbol={thisEnumValueSymbol}>
       <Name />
     </core.Declaration>;
 }
diff --git a/packages/csharp/src/components/Parameters.tsx b/packages/csharp/src/components/Parameters.tsx
index 4a1f53a..c577cda 100644
--- a/packages/csharp/src/components/Parameters.tsx
+++ b/packages/csharp/src/components/Parameters.tsx
@@ -1,17 +1,51 @@
 import * as core from "@alloy-js/core";
 import { useCSharpNamePolicy } from "../name-policy.js";
+import { CSharpOutputSymbol } from "../symbols/csharp-output-symbol.js";
+import { useCSharpScope } from "../symbols/scopes.js";
+import { Name } from "./Name.js";
+
+export interface ParameterProps {
+  name: string;
+  type: core.Children;
+  refkey?: core.Refkey;
+  symbol?: core.OutputSymbol;
+}
+
+// a constructor/method parameter
+export function Parameter(props: ParameterProps) {
+  const name = useCSharpNamePolicy().getName(props.name, "parameter");
+  const scope = useCSharpScope();
+  if (
+    scope.kind !== "member" ||
+    (scope.name !== "constructor-decl" && scope.name !== "method-decl")
+  ) {
+    throw new Error(
+      "can't define a parameter outside of a constructor-decl or method-decl scope",
+    );
+  }
+
+  const memberSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
+    name: name,
+    scope,
+    refkey: props.refkey ?? core.refkey(props.name),
+  });
+
+  return <core.Declaration symbol={memberSymbol}>
+      {props.type} <Name />
+    </core.Declaration>;
+}
 
 export interface ParametersProps {
   // param name and type
-  parameters: Record<string, core.Child>;
+  parameters: Array<ParameterProps>;
 }
 
-// param names and type
+// a collection of parameters
 export function Parameters(props: ParametersProps): Array<core.Child | string> {
   return core.mapJoin(
-    new Map(Object.entries(props.parameters)),
-    (name, type) => {
-      return [type, " ", useCSharpNamePolicy().getName(name, "parameter")];
+    props.parameters,
+    (param) => {
+      return <Parameter {...param} />;
     },
     { joiner: ", " },
   );
diff --git a/packages/csharp/src/components/ProjectDirectory.tsx b/packages/csharp/src/components/ProjectDirectory.tsx
index e68d51d..90da74c 100644
--- a/packages/csharp/src/components/ProjectDirectory.tsx
+++ b/packages/csharp/src/components/ProjectDirectory.tsx
@@ -18,6 +18,9 @@ export interface ProjectDirectoryProps {
   // directory name for source files. defaults to "src"
   srcDir?: string;
 
+  // the .NET TFM for this project. defaults to net8.0
+  targetFrameworkMoniker?: string;
+
   // child components of the project
   children?: core.Children;
 }
@@ -28,6 +31,10 @@ export function ProjectDirectory(props: ProjectDirectoryProps) {
     props.srcDir = "src";
   }
 
+  if (!props.targetFrameworkMoniker) {
+    props.targetFrameworkMoniker = "net8.0";
+  }
+
   return <core.SourceDirectory path={join(props.path, props.name)}>
     <core.SourceFile path={props.name+".csproj"} filetype="xml">
       {core.code`
@@ -35,6 +42,7 @@ export function ProjectDirectory(props: ProjectDirectoryProps) {
           <PropertyGroup>
             <Version>${props.version}</Version>
             <Description>${props.description}</Description>
+            <TargetFramework>${props.targetFrameworkMoniker}</TargetFramework>
           </PropertyGroup>
         </Project>
       `}
diff --git a/packages/csharp/src/components/stc/index.ts b/packages/csharp/src/components/stc/index.ts
index 9953f0f..ec0b396 100644
--- a/packages/csharp/src/components/stc/index.ts
+++ b/packages/csharp/src/components/stc/index.ts
@@ -2,10 +2,12 @@ import * as core from "@alloy-js/core";
 import * as base from "../index.js";
 
 export const Class = core.stc(base.Class);
+export const ClassConstructor = core.stc(base.ClassConstructor);
 export const ClassMember = core.stc(base.ClassMember);
 export const ClassMethod = core.stc(base.ClassMethod);
 export const Enum = core.stc(base.Enum);
 export const EnumMember = core.stc(base.EnumMember);
+export const Parameter = core.stc(base.Parameter);
 export const Parameters = core.stc(base.Parameters);
 export const ProjectDirectory = core.stc(base.ProjectDirectory);
 export const UsingDirective = core.stc(base.UsingDirective);
diff --git a/packages/csharp/src/name-policy.ts b/packages/csharp/src/name-policy.ts
index 530ec67..4bf240e 100644
--- a/packages/csharp/src/name-policy.ts
+++ b/packages/csharp/src/name-policy.ts
@@ -11,7 +11,8 @@ export type CSharpElements =
   | "interface"
   | "class-member"
   | "class-method"
-  | "parameter";
+  | "parameter"
+  | "type-parameter";
 
 // creates the C# naming policy
 export function createCSharpNamePolicy(): core.NamePolicy<CSharpElements> {
@@ -22,6 +23,7 @@ export function createCSharpNamePolicy(): core.NamePolicy<CSharpElements> {
       case "enum-member":
       case "interface":
       case "class-method":
+      case "type-parameter":
         return changecase.pascalCase(name);
       case "constant":
         return changecase.constantCase(name);
diff --git a/packages/csharp/src/symbols/scopes.ts b/packages/csharp/src/symbols/scopes.ts
index 7d864ed..7c2925e 100644
--- a/packages/csharp/src/symbols/scopes.ts
+++ b/packages/csharp/src/symbols/scopes.ts
@@ -20,7 +20,11 @@ export function createCSharpNamespaceScope(
 }
 
 // the kind of member scope. i.e. are we in an enum, class, etc
-export type CSharpMemberScopeName = "class" | "enum" | "method";
+export type CSharpMemberScopeName =
+  | "class-decl"
+  | "constructor-decl"
+  | "enum-decl"
+  | "method-decl";
 
 // indicates that the scope for a symbol resides within a type
 // e.g. for an enum value, class field etc, these would have


Test patch:
diff --git a/packages/csharp/test/class.test.tsx b/packages/csharp/test/class.test.tsx
index e8f11a1..f084f07 100644
--- a/packages/csharp/test/class.test.tsx
+++ b/packages/csharp/test/class.test.tsx
@@ -58,10 +58,16 @@ it("declares class with some methods", () => {
 });
 
 it("declares class with params and return type", () => {
-  const params = {
-    IntParam: "int",
-    StringParam: "string",
-  };
+  const params = [
+    {
+      name: "IntParam",
+      type: "int",
+    },
+    {
+      name: "StringParam",
+      type: "string",
+    },
+  ];
   const res = utils.toSourceText(
     <csharp.Class accessModifier='public' name="TestClass">
       <csharp.ClassMethod accessModifier="public" name="MethodOne" parameters={params} returns="string" />
@@ -81,10 +87,16 @@ it("declares class with params and return type", () => {
 
 it("uses refkeys for members, params, and return type", () => {
   const inputTypeRefkey = core.refkey();
-  const params = {
-    IntParam: "int",
-    BodyParam: inputTypeRefkey,
-  };
+  const params = [
+    {
+      name: "IntParam",
+      type: "int",
+    },
+    {
+      name: "BodyParam",
+      type: inputTypeRefkey,
+    },
+  ];
 
   const res = core.render(
     <core.Output namePolicy={csharp.createCSharpNamePolicy()}>
@@ -162,6 +174,73 @@ it("declares class with invalid members", () => {
     </csharp.Class>;
 
   expect(() => utils.toSourceText(decl)).toThrow(
-    "can't define an enum member outside of an enum scope",
+    "can't define an enum member outside of an enum-decl scope",
+  );
+});
+
+it("declares class with constructor", () => {
+  const res = utils.toSourceText(
+    <csharp.Class accessModifier='public' name="TestClass">
+      <csharp.ClassConstructor accessModifier="public" />
+    </csharp.Class>,
+  );
+
+  expect(res).toBe(coretest.d`
+    namespace TestCode
+    {
+      public class TestClass
+      {
+        public TestClass() {}
+      }
+    }
+  `);
+});
+
+it("declares class with constructor params and assigns values to fields", () => {
+  const thisNameRefkey = core.refkey();
+  const thisSizeRefkey = core.refkey();
+  const paramNameRefkey = core.refkey();
+  const paramSizeRefkey = core.refkey();
+
+  const ctorParams = [
+    {
+      name: "name",
+      type: "string",
+      refkey: paramNameRefkey,
+    },
+    {
+      name: "size",
+      type: "int",
+      refkey: paramSizeRefkey,
+    },
+  ];
+
+  const res = utils.toSourceText(
+    <csharp.Class accessModifier='public' name="TestClass">
+      <csharp.ClassMember accessModifier="private" name="name" type="string" refkey={thisNameRefkey} />
+      <csharp.ClassMember accessModifier="private" name="size" type="int" refkey={thisSizeRefkey} />
+      <csharp.ClassConstructor accessModifier="public" parameters={ctorParams}>
+        {thisNameRefkey} = {paramNameRefkey};
+        {thisSizeRefkey} = {paramSizeRefkey};
+      </csharp.ClassConstructor>
+    </csharp.Class>,
   );
+
+  // TODO: assignments to members should have this. prefix
+  // e.g. this.name = name;
+  expect(res).toBe(coretest.d`
+    namespace TestCode
+    {
+      public class TestClass
+      {
+        private string name;
+        private int size;
+        public TestClass(string name, int size)
+        {
+          name = name;
+          size = size;
+        }
+      }
+    }
+  `);
 });
diff --git a/packages/csharp/test/projectdirectory.test.tsx b/packages/csharp/test/projectdirectory.test.tsx
index ef8517e..345612d 100644
--- a/packages/csharp/test/projectdirectory.test.tsx
+++ b/packages/csharp/test/projectdirectory.test.tsx
@@ -30,6 +30,58 @@ it("defines a project directory file with multiple source files", () => {
       <PropertyGroup>
         <Version>0.1.0</Version>
         <Description>a test project</Description>
+        <TargetFramework>net8.0</TargetFramework>
+      </PropertyGroup>
+    </Project>
+  `);
+
+  const srcDir = projDir.contents[1] as core.OutputDirectory;
+
+  expect(srcDir.contents[0].path).equals("~/projects/TestProject/src/Test1.cs");
+  expect(srcDir.contents[0].contents).toBe(coretest.d`
+    namespace TestCode
+    {
+      public class TestClass1;
+    }
+  `);
+
+  expect(srcDir.contents[1].path).equals("~/projects/TestProject/src/Test2.cs");
+  expect(srcDir.contents[1].contents).toBe(coretest.d`
+    namespace TestCode
+    {
+      public class TestClass2;
+    }
+  `);
+});
+
+it("defines a project directory file with multiple source files and a custom TFM", () => {
+  const res = core.render(
+    <core.Output>
+      <csharp.ProjectDirectory name="TestProject" path="~/projects" version="0.1.0" description="a test project" targetFrameworkMoniker="netstandard2.1">
+        <csharp.Namespace name='TestCode'>
+          <csharp.SourceFile path="Test1.cs">
+            <csharp.Class accessModifier='public' name="TestClass1" />
+          </csharp.SourceFile>
+          <csharp.SourceFile path="Test2.cs">
+            <csharp.Class accessModifier='public' name="TestClass2" />
+          </csharp.SourceFile>
+        </csharp.Namespace>
+      </csharp.ProjectDirectory>
+    </core.Output>,
+  );
+
+  const projDir = res.contents[0] as core.OutputDirectory;
+
+  expect(projDir.path).equals("~/projects/TestProject");
+  expect(projDir.contents[0].path).equals(
+    "~/projects/TestProject/TestProject.csproj",
+  );
+  expect(projDir.contents[0].contents).toBe(coretest.d`
+    <Project Sdk="Microsoft.NET.Sdk">
+      <PropertyGroup>
+        <Version>0.1.0</Version>
+        <Description>a test project</Description>
+        <TargetFramework>netstandard2.1</TargetFramework>
       </PropertyGroup>
     </Project>
   `);
diff --git a/packages/csharp/test/using.test.tsx b/packages/csharp/test/using.test.tsx
index 4e4f14b..2a2a4cb 100644
--- a/packages/csharp/test/using.test.tsx
+++ b/packages/csharp/test/using.test.tsx
@@ -36,9 +36,12 @@ it("uses multiple namespaces", () => {
 
 it("adds using statement across namespaces", () => {
   const inputTypeRefkey = core.refkey();
-  const params = {
-    BodyParam: inputTypeRefkey,
-  };
+  const params = [
+    {
+      name: "BodyParam",
+      type: inputTypeRefkey,
+    },
+  ];
 
   const res = core.render(
     <core.Output namePolicy={csharp.createCSharpNamePolicy()}>