Repository: alloy-framework/alloy
Pull request #215 (2025-06-24 15:53:58)

Add support for Record declaration


Patch:
diff --git a/.chronus/changes/record-decl-2025-5-24-15-54-21.md b/.chronus/changes/record-decl-2025-5-24-15-54-21.md
new file mode 100644
index 0000000..2ff761a
--- /dev/null
+++ b/.chronus/changes/record-decl-2025-5-24-15-54-21.md
@@ -0,0 +1,8 @@
+---
+# Change versionKind to one of: breaking, feature, fix, internal
+changeKind: feature
+packages:
+  - "@alloy-js/csharp"
+---
+
+Add support for `record` with `<RecordDeclaration>` component
diff --git a/packages/csharp/src/components/class/property.tsx b/packages/csharp/src/components/class/property.tsx
index bc42b39..cc35081 100644
--- a/packages/csharp/src/components/class/property.tsx
+++ b/packages/csharp/src/components/class/property.tsx
@@ -94,7 +94,10 @@ export interface ClassPropertyProps
 export function ClassProperty(props: ClassPropertyProps) {
   const name = useCSharpNamePolicy().getName(props.name, "class-property");
   const scope = useCSharpScope();
-  if (scope.kind !== "member" || scope.name !== "class-decl") {
+  if (
+    scope.kind !== "member" ||
+    (scope.name !== "class-decl" && scope.name !== "record-decl")
+  ) {
     throw new Error(
       "can't define an interface method outside of an interface scope",
     );
diff --git a/packages/csharp/src/components/record/declaration.tsx b/packages/csharp/src/components/record/declaration.tsx
new file mode 100644
index 0000000..bd00c65
--- /dev/null
+++ b/packages/csharp/src/components/record/declaration.tsx
@@ -0,0 +1,109 @@
+import * as core 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 } from "../../symbols/scopes.js";
+import { DocWhen } from "../doc/comment.jsx";
+import { Name } from "../Name.jsx";
+
+export interface RecordModifiers {
+  readonly partial?: boolean;
+}
+
+const getRecordModifiers = makeModifiers<RecordModifiers>(["partial"]);
+
+// properties for creating a class
+export interface RecordDeclarationProps
+  extends Omit<core.DeclarationProps, "nameKind">,
+    AccessModifiers,
+    RecordModifiers {
+  name: string;
+
+  /** Doc comment */
+  doc?: core.Children;
+  refkey?: core.Refkey;
+  typeParameters?: Record<string, core.Refkey>;
+}
+
+/**
+ * CSharp record declaration.
+ * @example
+ * ```tsx
+ * <RecordDeclaration public name="IMyRecord">
+ *   <RecordMember public name="MyProperty" type="int" />
+ *   <RecordMethod public name="MyMethod" returnType="void">
+ *     <Parameter name="value" type="int" />
+ *   </RecordMethod>
+ * </RecordDeclaration>
+ * ```
+ * This will produce:
+ * ```csharp
+ * public record MyIface
+ * {
+ *   public int MyProperty { get; set; }
+ *   public void MyMethod(int value);
+ * }
+ * ```
+ */
+export function RecordDeclaration(props: RecordDeclarationProps) {
+  const name = useCSharpNamePolicy().getName(props.name!, "record");
+
+  const thisRecordSymbol = new CSharpOutputSymbol(name, {
+    refkeys: props.refkey,
+  });
+
+  // this creates a new scope for the record definition.
+  // members will automatically "inherit" this scope so
+  // that refkeys to them will produce the fully-qualified
+  // name e.g. Foo.Bar.
+  const thisRecordScope = new CSharpMemberScope("record-decl", {
+    owner: thisRecordSymbol,
+  });
+
+  let typeParams: core.Children;
+  if (props.typeParameters) {
+    const typeParamNames = new Array<string>();
+    for (const entry of Object.entries(props.typeParameters)) {
+      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
+      new CSharpOutputSymbol(entry[0], {
+        scope: thisRecordScope,
+        refkeys: entry[1],
+      });
+    }
+    typeParams = (
+      <group>
+        {"<"}
+        <core.For each={typeParamNames} comma line>
+          {(name) => name}
+        </core.For>
+        {">"}
+      </group>
+    );
+  }
+
+  const modifiers = computeModifiersPrefix([
+    getAccessModifier(props),
+    getRecordModifiers(props),
+  ]);
+  return (
+    <core.Declaration symbol={thisRecordSymbol}>
+      <DocWhen doc={props.doc} />
+      {modifiers}record <Name />
+      {typeParams}
+      {props.children ?
+        <core.Block newline>
+          <core.Scope value={thisRecordScope}>{props.children}</core.Scope>
+        </core.Block>
+      : ";"}
+    </core.Declaration>
+  );
+}
diff --git a/packages/csharp/src/name-policy.ts b/packages/csharp/src/name-policy.ts
index c90c7f3..dcc1d16 100644
--- a/packages/csharp/src/name-policy.ts
+++ b/packages/csharp/src/name-policy.ts
@@ -9,6 +9,7 @@ export type CSharpElements =
   | "enum-member"
   | "function"
   | "interface"
+  | "record"
   | "class-member-private"
   | "class-member-public"
   | "class-method"
@@ -24,6 +25,7 @@ export function createCSharpNamePolicy(): core.NamePolicy<CSharpElements> {
       case "enum":
       case "enum-member":
       case "interface":
+      case "record":
       case "class-member-public":
       case "class-method":
       case "type-parameter":


Test patch:
diff --git a/packages/csharp/src/components/record/declaration.test.tsx b/packages/csharp/src/components/record/declaration.test.tsx
new file mode 100644
index 0000000..a6cf30e
--- /dev/null
+++ b/packages/csharp/src/components/record/declaration.test.tsx
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { TestNamespace } from "../../../test/utils.jsx";
+import { ClassProperty } from "../class/property.jsx";
+import { RecordDeclaration } from "./declaration.jsx";
+
+it("declares class with no members", () => {
+  expect(
+    <TestNamespace>
+      <RecordDeclaration name="TestRecord" />
+    </TestNamespace>,
+  ).toRenderTo(`
+      record TestRecord;
+  `);
+});
+
+describe("modifiers", () => {
+  it.each(["public", "private", "internal"])("%s", (mod) => {
+    expect(
+      <TestNamespace>
+        <RecordDeclaration {...{ [mod]: true }} name="TestRecord" />
+      </TestNamespace>,
+    ).toRenderTo(`
+        ${mod} record TestRecord;
+    `);
+  });
+
+  it.each(["partial"])("%s", (mod) => {
+    expect(
+      <TestNamespace>
+        <RecordDeclaration {...{ [mod]: true }} name="TestRecord" />
+      </TestNamespace>,
+    ).toRenderTo(`
+        ${mod} record TestRecord;
+    `);
+  });
+
+  it("combines modifiers", () => {
+    expect(
+      <TestNamespace>
+        <RecordDeclaration public partial name="TestRecord" />
+      </TestNamespace>,
+    ).toRenderTo(`
+        public partial record TestRecord;
+    `);
+  });
+});
+
+it("specify doc comment", () => {
+  expect(
+    <TestNamespace>
+      <RecordDeclaration name="TestRecord" doc="This is a test" />
+    </TestNamespace>,
+  ).toRenderTo(`
+    /// This is a test
+    record TestRecord;
+  `);
+});
+
+it("specify class property inside", () => {
+  expect(
+    <TestNamespace>
+      <RecordDeclaration name="TestRecord" doc="This is a test">
+        <ClassProperty name="Prop" get set type="string" />
+      </RecordDeclaration>
+    </TestNamespace>,
+  ).toRenderTo(`
+    /// This is a test
+    record TestRecord
+    {
+      string Prop { get; set; }
+    }
+  `);
+});