diff --git a/Coder.Test/Languages/CSharpGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/CSharpGeneratedSourceCompilesTests.cs
index 405f296..9182329 100644
--- a/Coder.Test/Languages/CSharpGeneratedSourceCompilesTests.cs
+++ b/Coder.Test/Languages/CSharpGeneratedSourceCompilesTests.cs
@@ -1,4 +1,4 @@
-// Copyright (c) 2023-2026 ktsu-dev contributors
+// Copyright (c) 2023-2026 ktsu-dev contributors
namespace ktsu.Coder.Test.Languages;
@@ -86,4 +86,59 @@ public void ScopeMembers_Compile()
Assert.AreEqual(0, exitCode, $"C# rejected the generated source:{Environment.NewLine}{output}");
});
}
+
+ ///
+ /// The consumer of the generated operators, which uses each the way C# code would.
+ ///
+ ///
+ /// a + b is what catches a binary operator written as a unary one: the declaration
+ /// compiles either way, and only a call site with two operands refuses it.
+ ///
+ private const string OperatorDriver = """
+ public static class OperatorDriver
+ {
+ public static double Use()
+ {
+ Point a = new Point(1, 2);
+ Point b = new Point(3);
+ Point sum = a + b;
+ Point negated = -a;
+ double converted = a;
+ return converted + (double)sum + (double)negated;
+ }
+ }
+ """;
+
+ ///
+ /// Tests that a type's binary, unary and conversion operators compile, and that a caller can use
+ /// each with the operands the declaration describes.
+ ///
+ [TestMethod]
+ public void Operators_Compile()
+ {
+ if (ToolchainHarness.FindOnPath("--version", "dotnet") is null)
+ {
+ Assert.Inconclusive("No dotnet on the path, so nothing was compiled.");
+ return;
+ }
+
+ ClassDeclaration point = CompiledExemplar.Point();
+ point.Members.Add(CompiledExemplar.Plus());
+ point.Members.Add(CompiledExemplar.Negate());
+ point.Members.Add(CompiledExemplar.ToDouble("self.x"));
+
+ SourceFile file = new("Generated");
+ file.Members.Add(point);
+
+ ToolchainHarness.InTemporaryDirectory(directory =>
+ {
+ string source = new CSharpGenerator().Generate(file);
+ File.WriteAllText(Path.Combine(directory, "Generated.csproj"), Project);
+ File.WriteAllText(Path.Combine(directory, "Driver.cs"), OperatorDriver);
+ File.WriteAllText(Path.Combine(directory, "Generated.cs"), source);
+
+ (int exitCode, string output) = ToolchainHarness.Run("dotnet", "build -nologo", directory);
+ Assert.AreEqual(0, exitCode, $"C# rejected the generated source:{Environment.NewLine}{output}{Environment.NewLine}{source}");
+ });
+ }
}
diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs
index 152022b..bb2a428 100644
--- a/Coder/Languages/CSharpGenerator.cs
+++ b/Coder/Languages/CSharpGenerator.cs
@@ -883,9 +883,15 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, Cl
code.Write(SpellTypeParameters(function.TypeParameters));
code.Write("(");
+ bool takesOperand = TakesImplicitOperand(function, enclosingType);
+ if (takesOperand)
+ {
+ code.Write($"{enclosingType!.Name}{SpellTypeParameters(enclosingType.TypeParameters)} {OperandParameterName}");
+ }
+
for (int i = 0; i < function.Parameters.Count; i++)
{
- if (i > 0)
+ if (i > 0 || takesOperand)
{
code.Write(", ");
}
@@ -1042,6 +1048,30 @@ private void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker co
}
}
+ ///
+ /// What the operand an operator acts on is called, once C# has to name it.
+ ///
+ ///
+ /// self, as the C lowering names its receiver, so a body that reads self.x means the
+ /// same thing in both.
+ ///
+ private const string OperandParameterName = "self";
+
+ ///
+ /// Determines whether a declaration's operand is one C# has to write as a parameter.
+ ///
+ /// The declaration being emitted.
+ /// The type it belongs to, when it belongs to one.
+ /// True when it is an operator or a conversion that belongs to a type.
+ ///
+ /// The AST's operators are members, as C++'s and Rust's are: operator+(rhs) is binary, and
+ /// the instance it belongs to is the left-hand operand. A C# operator is static and takes every
+ /// operand explicitly, so written as declared a binary operator would become a unary one and a
+ /// unary operator or a conversion would take nothing at all (CS1019).
+ ///
+ private static bool TakesImplicitOperand(FunctionDeclaration function, ClassDeclaration? enclosingType) =>
+ enclosingType is not null && function.Kind is FunctionKind.Operator or FunctionKind.ConversionOperator;
+
///
/// Determines whether a declaration's is spelled as
/// C#'s readonly.
@@ -1051,10 +1081,14 @@ private void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker co
/// True when the declaration is an instance member of a struct that claims it.
///
/// The claim is C++'s trailing const, which any member function may make. C# lets only an
- /// instance member of a struct make it, so everywhere else readonly fails with CS0106.
+ /// instance member of a struct make it, so everywhere else readonly fails with CS0106. An
+ /// operator is static in C# whether or not the declaration says so, so it never can.
///
private static bool IsReadOnlySpelled(FunctionDeclaration function, ClassDeclaration? enclosingType) =>
- function.IsReadOnly && !function.IsStatic && enclosingType?.Kind == TypeDeclarationKind.Struct;
+ function.IsReadOnly
+ && !function.IsStatic
+ && function.Kind is not (FunctionKind.Operator or FunctionKind.ConversionOperator)
+ && enclosingType?.Kind == TypeDeclarationKind.Struct;
///
/// Determines whether a class is written abstract.