Repository: alloy-framework/alloy
Pull request #209 (2025-06-20 20:37:28)

Add support for class properties


Patch:
diff --git a/.chronus/changes/class-properties-2025-5-20-20-49-48.md b/.chronus/changes/class-properties-2025-5-20-20-49-48.md
new file mode 100644
index 0000000..8eb85f8
--- /dev/null
+++ b/.chronus/changes/class-properties-2025-5-20-20-49-48.md
@@ -0,0 +1,8 @@
+---
+# Change versionKind to one of: breaking, feature, fix, internal
+changeKind: feature
+packages:
+  - "@alloy-js/csharp"
+---
+
+Add `<ClassProperty />` component
diff --git a/packages/csharp/src/components/class/property.tsx b/packages/csharp/src/components/class/property.tsx
new file mode 100644
index 0000000..bc42b39
--- /dev/null
+++ b/packages/csharp/src/components/class/property.tsx
@@ -0,0 +1,135 @@
+import {
+  Block,
+  Children,
+  code,
+  List,
+  MemberDeclaration,
+  refkey,
+  Refkey,
+  Scope,
+} from "@alloy-js/core";
+import {
+  AccessModifiers,
+  computeModifiersPrefix,
+  getAccessModifier,
+  makeModifiers,
+} from "../../modifiers.js";
+import { useCSharpNamePolicy } from "../../name-policy.js";
+import { CSharpOutputSymbol } from "../../symbols/csharp-output-symbol.js";
+import { CSharpMemberScope, useCSharpScope } from "../../symbols/scopes.js";
+import { DocWhen } from "../doc/comment.jsx";
+
+/** Method modifiers. Can only be one. */
+export interface ClassPropertyModifiers {
+  readonly new?: boolean;
+  readonly static?: boolean;
+  readonly virtual?: boolean;
+  readonly sealed?: boolean;
+  readonly override?: boolean;
+  readonly abstract?: boolean;
+  readonly extern?: boolean;
+  readonly readonly?: boolean;
+}
+
+const getModifiers = makeModifiers<ClassPropertyModifiers>([
+  "new",
+  "static",
+  "virtual",
+  "sealed",
+  "override",
+  "abstract",
+  "extern",
+  "readonly",
+]);
+
+/** Properties for {@link ClassProperty} component */
+export interface ClassPropertyProps
+  extends AccessModifiers,
+    ClassPropertyModifiers {
+  name: string;
+  refkey?: Refkey;
+
+  /** Property type */
+  type: Children;
+
+  /** If property should have a getter */
+  get?: boolean;
+
+  /** If property should have a setter */
+  set?: boolean;
+
+  /** Doc comment */
+  doc?: Children;
+
+  /**
+   * Property initializer
+   * @example `<ClassProperty name="My" get set nullable />`
+   *
+   * ```cs
+   * int? My { get; set; };
+   * ```
+   */
+  nullable?: boolean;
+
+  /**
+   * Property initializer
+   * @example `<ClassProperty name="My" get set init={42} />`
+   *
+   * ```cs
+   * int My { get; set; } = 42;
+   * ```
+   */
+  init?: Children;
+}
+
+/**
+ * Render a C# class property.
+ *
+ * @example `<ClassProperty public name="My" get set  />`
+ *
+ * ```cs
+ * public int My { get; set; };
+ * ```
+ */
+export function ClassProperty(props: ClassPropertyProps) {
+  const name = useCSharpNamePolicy().getName(props.name, "class-property");
+  const scope = useCSharpScope();
+  if (scope.kind !== "member" || scope.name !== "class-decl") {
+    throw new Error(
+      "can't define an interface method outside of an interface scope",
+    );
+  }
+
+  const propertySymbol = new CSharpOutputSymbol(name, {
+    scope,
+    refkeys: props.refkey ?? refkey(props.name),
+  });
+
+  // scope for property declaration
+  const propertyScope = new CSharpMemberScope("property-decl", {
+    owner: propertySymbol,
+  });
+
+  const modifiers = computeModifiersPrefix([
+    getAccessModifier(props),
+    getModifiers(props),
+  ]);
+  // note that scope wraps the method decl so that the params get the correct scope
+  return (
+    <MemberDeclaration symbol={propertySymbol}>
+      <Scope value={propertyScope}>
+        <DocWhen doc={props.doc} />
+        {modifiers}
+        {props.type}
+        {props.nullable && "?"} {name}{" "}
+        <Block newline inline>
+          <List joiner=" ">
+            {props.get && "get;"}
+            {props.set && "set;"}
+          </List>
+        </Block>
+        {props.init && code` = ${props.init};`}
+      </Scope>
+    </MemberDeclaration>
+  );
+}
diff --git a/packages/csharp/src/components/index.ts b/packages/csharp/src/components/index.ts
index 84d0681..3beba70 100644
--- a/packages/csharp/src/components/index.ts
+++ b/packages/csharp/src/components/index.ts
@@ -1,3 +1,4 @@
+export * from "./class/property.js";
 export * from "./ClassDeclaration.jsx";
 export * from "./ClassMethod.jsx";
 export * from "./Declaration.js";
@@ -6,6 +7,7 @@ export * from "./doc/from-markdown.jsx";
 export * from "./EnumDeclaration.jsx";
 export * from "./interface/declaration.js";
 export * from "./interface/method.js";
+export * from "./interface/property.js";
 export * from "./Name.js";
 export * from "./Namespace.js";
 export * from "./Parameters.js";
diff --git a/packages/csharp/src/components/interface/property.tsx b/packages/csharp/src/components/interface/property.tsx
index 983e8ba..9c0cac6 100644
--- a/packages/csharp/src/components/interface/property.tsx
+++ b/packages/csharp/src/components/interface/property.tsx
@@ -23,9 +23,7 @@ export interface InterfacePropertyModifiers {
   readonly new?: boolean;
 }
 
-export const getMethodModifier = makeModifiers<InterfacePropertyModifiers>([
-  "new",
-]);
+const getModifiers = makeModifiers<InterfacePropertyModifiers>(["new"]);
 
 // properties for creating a method
 export interface InterfacePropertyProps
@@ -45,9 +43,27 @@ export interface InterfacePropertyProps
 
   /** Doc comment */
   doc?: Children;
+
+  /**
+   * Property initializer
+   * @example `<ClassProperty name="My" get set nullable />`
+   *
+   * ```cs
+   * int? My { get; set; };
+   * ```
+   */
+  nullable?: boolean;
 }
 
-// a C# interface property
+/**
+ * Render a C# interface property.
+ *
+ * @example `<InterfaceProperty public name="My" get set  />`
+ *
+ * ```cs
+ * public int My { get; set; };
+ * ```
+ */
 export function InterfaceProperty(props: InterfacePropertyProps) {
   const name = useCSharpNamePolicy().getName(props.name, "class-property");
   const scope = useCSharpScope();
@@ -69,7 +85,7 @@ export function InterfaceProperty(props: InterfacePropertyProps) {
 
   const modifiers = computeModifiersPrefix([
     getAccessModifier(props),
-    getMethodModifier(props),
+    getModifiers(props),
   ]);
   // note that scope wraps the method decl so that the params get the correct scope
   return (
@@ -77,7 +93,8 @@ export function InterfaceProperty(props: InterfacePropertyProps) {
       <Scope value={propertyScope}>
         <DocWhen doc={props.doc} />
         {modifiers}
-        {props.type} {name}{" "}
+        {props.type}
+        {props.nullable && "?"} {name}{" "}
         <Block newline inline>
           <List joiner=" ">
             {props.get && "get;"}


Test patch:
diff --git a/packages/csharp/src/components/class/property.test.tsx b/packages/csharp/src/components/class/property.test.tsx
new file mode 100644
index 0000000..a981512
--- /dev/null
+++ b/packages/csharp/src/components/class/property.test.tsx
@@ -0,0 +1,180 @@
+import { Children } from "@alloy-js/core/jsx-runtime";
+import { describe, expect, it } from "vitest";
+import { TestNamespace } from "../../../test/utils.jsx";
+import { ClassDeclaration } from "../ClassDeclaration.jsx";
+import { ClassProperty } from "./property.jsx";
+
+const Wrapper = (props: { children: Children }) => (
+  <TestNamespace>
+    <ClassDeclaration public name="TestClass">
+      {props.children}
+    </ClassDeclaration>
+  </TestNamespace>
+);
+
+describe("modifiers", () => {
+  describe("access modifiers", () => {
+    it.each(["public", "private", "protected", "internal"] as const)(
+      "%s",
+      (accessModifier) => {
+        expect(
+          <Wrapper>
+            <ClassProperty
+              {...{ [accessModifier]: true }}
+              name="TestProp"
+              type="string"
+              get
+            />
+          </Wrapper>,
+        ).toRenderTo(`
+        public class TestClass
+        {
+          ${accessModifier} string TestProp { get; }
+        }
+      `);
+      },
+    );
+  });
+
+  describe("property modifiers", () => {
+    it.each([
+      "new",
+      "static",
+      "virtual",
+      "sealed",
+      "override",
+      "abstract",
+      "extern",
+      "readonly",
+    ] as const)("%s", (methodModifier) => {
+      expect(
+        <Wrapper>
+          <ClassProperty
+            {...{ [methodModifier]: true }}
+            name="TestProp"
+            type="string"
+            get
+          />
+        </Wrapper>,
+      ).toRenderTo(`
+        public class TestClass
+        {
+          ${methodModifier} string TestProp { get; }
+        }
+      `);
+    });
+  });
+
+  it("combine modifiers", () => {
+    expect(
+      <Wrapper>
+        <ClassProperty public new name="TestProp" type="string" get />
+      </Wrapper>,
+    ).toRenderTo(`
+        public class TestClass
+        {
+          public new string TestProp { get; }
+        }
+      `);
+  });
+});
+
+it("applies PascalCase naming policy", () => {
+  expect(
+    <Wrapper>
+      <ClassProperty name="test_prop" type="string" get />
+    </Wrapper>,
+  ).toRenderTo(`
+    public class TestClass
+    {
+      string TestProp { get; }
+    }
+`);
+});
+
+it("has getter only", () => {
+  expect(
+    <Wrapper>
+      <ClassProperty name="TestProp" type="string" get />
+    </Wrapper>,
+  ).toRenderTo(`
+    public class TestClass
+    {
+      string TestProp { get; }
+    }
+  `);
+});
+
+it("has setter only", () => {
+  expect(
+    <Wrapper>
+      <ClassProperty name="TestProp" type="string" set />
+    </Wrapper>,
+  ).toRenderTo(`
+    public class TestClass
+    {
+      string TestProp { set; }
+    }
+  `);
+});
+
+it("has getter and setter", () => {
+  expect(
+    <Wrapper>
+      <ClassProperty name="TestProp" type="string" get set />
+    </Wrapper>,
+  ).toRenderTo(`
+    public class TestClass
+    {
+      string TestProp { get; set; }
+    }
+  `);
+});
+
+it("specify doc comment", () => {
+  expect(
+    <TestNamespace>
+      <ClassDeclaration name="Test">
+        <ClassProperty
+          name="Method"
+          type="string"
+          get
+          set
+          doc="This is a test"
+        />
+      </ClassDeclaration>
+    </TestNamespace>,
+  ).toRenderTo(`
+    class Test
+    {
+      /// This is a test
+      string Method { get; set; }
+    }
+  `);
+});
+
+it("specify nullable property", () => {
+  expect(
+    <Wrapper>
+      <ClassProperty name="TestProp" type="string" nullable get set />
+    </Wrapper>,
+  ).toRenderTo(`
+    public class TestClass
+    {
+      string? TestProp { get; set; }
+    }
+  `);
+});
+
+it("specify initializer", () => {
+  expect(
+    <Wrapper>
+      <ClassProperty name="TestProp" type="string" get set init={`"abc"`} />
+    </Wrapper>,
+  ).toRenderTo(`
+    public class TestClass
+    {
+      string TestProp { get; set; } = "abc";
+    }
+  `);
+});