Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ source in seven target languages. The solution uses:
as a string can only paste it, so the caller would have to spell the target language itself.
`Parse` and `ToString` are inverses and text the grammar cannot read becomes a name holding it
verbatim, so a property that used to hold a string still takes and gives one.
- `Coder/Ast/Parameter.cs`'s `DefaultValue` — an `Expression`, not source text, for the reason
`TypeReference` is structure: `False` is Python's spelling and fails to compile in C# and C++, and
no spelling of a flag or a null works in every target. Each generator writes it with its own
literal writer; C, Go and Rust, which have no default arguments, write it into a comment with any
`*/` broken apart. `ParseDefaultValue`/`FormatDefaultValue` are the text form the YAML and the
editor's field hold, so a document from when it was text still reads: `true`/`false` in any case,
numbers and double-quoted strings become literals, and anything else is a name, written verbatim.
- `Coder/Ast/SourceFile.cs`, `NamespaceDeclaration.cs`, `EnumDeclaration.cs`, `FieldDeclaration.cs` —
what a generated *header* is made of rather than a snippet. `FieldDeclaration` is deliberately not
`VariableDeclaration`: a field with no initialiser is value-initialised, so a default-constructed
Expand Down
4 changes: 2 additions & 2 deletions Coder.Cli/ExpressionDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ private static FunctionDeclaration CreateComplexFunction()
ReturnType = "double"
};
function.Parameters.Add(new Parameter("baseScore", "double"));
function.Parameters.Add(new Parameter("multiplier", "double") { IsOptional = true, DefaultValue = "1.0" });
function.Parameters.Add(new Parameter("bonus", "double") { IsOptional = true, DefaultValue = "0.0" });
function.Parameters.Add(new Parameter("multiplier", "double") { IsOptional = true, DefaultValue = Literal.DecimalValue(1.0) });
function.Parameters.Add(new Parameter("bonus", "double") { IsOptional = true, DefaultValue = Literal.DecimalValue(0.0) });

// Complex calculation: (baseScore * multiplier) + bonus
BinaryExpression multiplication = new(
Expand Down
2 changes: 1 addition & 1 deletion Coder.Cli/ExtendedDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ private static void DemoComplexFunction()
function.Parameters.Add(new Parameter("principal", "double"));
function.Parameters.Add(new Parameter("rate", "double"));
function.Parameters.Add(new Parameter("time", "int"));
function.Parameters.Add(new Parameter("compound", "int") { IsOptional = true, DefaultValue = "1" });
function.Parameters.Add(new Parameter("compound", "int") { IsOptional = true, DefaultValue = Literal.Number(1) });

// Create complex expression: principal * (1 + rate/compound)^(compound*time)
// For demo, we'll create a simpler version: principal * (1 + rate * time)
Expand Down
4 changes: 2 additions & 2 deletions Coder.Cli/SampleCLI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ private static FunctionDeclaration CreateSampleFunction()
// Add parameters
function.Parameters.Add(new Parameter("a", "int"));
function.Parameters.Add(new Parameter("b", "int"));
function.Parameters.Add(new Parameter("include_debug", "bool") { IsOptional = true, DefaultValue = "False" });
function.Parameters.Add(new Parameter("include_debug", "bool") { IsOptional = true, DefaultValue = Literal.Bool(false) });

// Add function body
function.Body.Add(new ReturnStatement(new AstLeafNode<string>("a + b")));
Expand All @@ -125,7 +125,7 @@ private static void ShowAstDetails(FunctionDeclaration function)

foreach (Parameter param in function.Parameters)
{
string optionalInfo = param.IsOptional ? $" (optional, default: {param.DefaultValue})" : "";
string optionalInfo = param.IsOptional ? $" (optional, default: {Parameter.FormatDefaultValue(param.DefaultValue)})" : "";
Console.WriteLine($" - {param.Name}: {param.Type}{optionalInfo}");
}

Expand Down
4 changes: 2 additions & 2 deletions Coder.Graph/AstFields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ private static IReadOnlyList<AstField> OfCallable(AstNode node)
new("Name", AstFieldKind.Text, parameter.Name ?? string.Empty),
new("Type", AstFieldKind.Text, parameter.Type?.ToString() ?? string.Empty),
new("Optional", AstFieldKind.Flag, Spell(parameter.IsOptional)),
new("Default", AstFieldKind.Text, parameter.DefaultValue ?? string.Empty),
new("Default", AstFieldKind.Text, Parameter.FormatDefaultValue(parameter.DefaultValue) ?? string.Empty),
],

VariableDeclaration varDecl =>
Expand Down Expand Up @@ -505,7 +505,7 @@ private static bool TryWriteFunctionShape(AstNode node, string fieldName, string
(Parameter parameter, "Name") => Assign(() => parameter.Name = OrNull(value)),
(Parameter parameter, "Type") => Assign(() => parameter.Type = OrNull(value)),
(Parameter parameter, "Optional") => AssignFlag(value, optional => parameter.IsOptional = optional),
(Parameter parameter, "Default") => Assign(() => parameter.DefaultValue = OrNull(value)),
(Parameter parameter, "Default") => Assign(() => parameter.DefaultValue = Parameter.ParseDefaultValue(value)),

(VariableDeclaration varDecl, "Name") => value.Length > 0 && Assign(() => varDecl.Name = value),
(VariableDeclaration varDecl, "Type") => Assign(() => varDecl.Type = OrNull(value)),
Expand Down
263 changes: 263 additions & 0 deletions Coder.Test/Ast/ParameterDefaultValueTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Test.Ast;

using System;
using System.IO;
using ktsu.Coder.Ast;
using ktsu.Coder.Languages;
using ktsu.Coder.Serialization;
using ktsu.Coder.Test.Languages;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests for <see cref="Parameter.DefaultValue"/>: that it is spelled by each language rather than
/// pasted, and that the text form older documents hold still reads (issue #182).
/// </summary>
/// <remarks>
/// A default used to be target-language source text, so the README's own <c>DefaultValue = "False"</c>
/// compiled in Python and nowhere else, and no spelling of a flag worked in every target.
/// </remarks>
[TestClass]
public class ParameterDefaultValueTests
{
/// <summary>
/// Builds <c>describe(include_debug: bool = false) -> bool</c>, which returns its parameter.
/// </summary>
/// <returns>The function.</returns>
private static FunctionDeclaration Describe()
{
FunctionDeclaration function = new("describe") { ReturnType = "bool" };
function.Parameters.Add(new Parameter("include_debug", "bool") { IsOptional = true, DefaultValue = Literal.Bool(false) });
function.Body.Add(new ReturnStatement(new VariableReference("include_debug")));
return function;
}

/// <summary>
/// Tests that a <c>false</c> default is spelled each language's own way.
/// </summary>
/// <param name="language">The language, which names the generator.</param>
/// <param name="expected">What the parameter has to come out as.</param>
[TestMethod]
[DataRow("csharp", "bool include_debug = false")]
[DataRow("cpp", "bool include_debug = false")]
[DataRow("javascript", "include_debug = false")]
[DataRow("python", "include_debug: bool = False")]
[DataRow("c", "/* = false */")]
[DataRow("go", "/* = false */")]
[DataRow("rust", "/* = false */")]
public void AFlagDefaultIsSpelledByEachLanguage(string language, string expected)
{
ILanguageGenerator generator = language switch
{
"csharp" => new CSharpGenerator(),
"cpp" => new CppGenerator(),
"javascript" => new JavaScriptGenerator(),
"python" => new PythonGenerator(),
"c" => new CGenerator(),
"go" => new GoGenerator(),
"rust" => new RustGenerator(),
_ => throw new ArgumentOutOfRangeException(nameof(language)),
};

string generated = generator.Generate(Describe());

Assert.Contains(expected, generated, StringComparison.Ordinal, generated);
Assert.DoesNotContain("False", language == "python" ? string.Empty : generated, StringComparison.Ordinal, generated);
}

/// <summary>
/// Tests that the generated Python runs when called without the argument.
/// </summary>
[TestMethod]
public void TheFlagDefaultRunsInPython()
{
string? python = ToolchainHarness.FindOnPath("--version", "python3", "python");
if (python is null)
{
Assert.Inconclusive("No Python on the path, so nothing was run.");
return;
}

ToolchainHarness.InTemporaryDirectory(directory =>
{
File.WriteAllText(
Path.Join(directory, "driver.py"),
new PythonGenerator().Generate(Describe()) + "\nassert describe() is False\nassert describe(True) is True\n");

(int exitCode, string output) = ToolchainHarness.Run(python, "driver.py", directory);

Assert.AreEqual(0, exitCode, output);
});
}

/// <summary>
/// Tests that the generated JavaScript runs when called without the argument, rather than throwing
/// a <c>ReferenceError</c> for a name it has never heard of.
/// </summary>
[TestMethod]
public void TheFlagDefaultRunsInJavaScript()
{
string? node = ToolchainHarness.FindOnPath("--version", "node");
if (node is null)
{
Assert.Inconclusive("No Node.js on the path, so nothing was run.");
return;
}

ToolchainHarness.InTemporaryDirectory(directory =>
{
File.WriteAllText(
Path.Join(directory, "driver.js"),
new JavaScriptGenerator().Generate(Describe())
+ "\nif (describe() !== false || describe(true) !== true) { process.exit(1); }\n");

(int exitCode, string output) = ToolchainHarness.Run(node, "driver.js", directory);

Assert.AreEqual(0, exitCode, output);
});
}

/// <summary>
/// Tests that the generated C++ compiles when called without the argument.
/// </summary>
[TestMethod]
public void TheFlagDefaultCompilesInCpp()
{
string? compiler = ToolchainHarness.FindOnPath("--version", "g++", "clang++");
if (compiler is null)
{
Assert.Inconclusive("No C++ compiler on the path, so nothing was compiled.");
return;
}

ToolchainHarness.InTemporaryDirectory(directory =>
{
File.WriteAllText(
Path.Join(directory, "driver.cpp"),
new CppGenerator().Generate(Describe()) + "\nint main()\n{\n\treturn describe() ? 1 : 0;\n}\n");

(int exitCode, string output) = ToolchainHarness.Run(compiler, "-std=c++20 -Wall -Wextra -pedantic -c driver.cpp -o driver.o", directory);

Assert.AreEqual(0, exitCode, output);
});
}

/// <summary>
/// Tests that a default whose spelling holds <c>*/</c> does not end the comment a language with no
/// default arguments writes it into.
/// </summary>
/// <param name="language">The language, which names the generator.</param>
[TestMethod]
[DataRow("c")]
[DataRow("go")]
[DataRow("rust")]
public void ACommentedDefaultCannotEndItsComment(string language)
{
ILanguageGenerator generator = language switch
{
"c" => new CGenerator(),
"go" => new GoGenerator(),
_ => new RustGenerator(),
};

FunctionDeclaration function = new("greet") { ReturnType = "int" };
function.Parameters.Add(new Parameter("text", "string") { IsOptional = true, DefaultValue = Literal.Text("a */ b") });
function.Body.Add(new ReturnStatement(Literal.Number(0)));

string generated = generator.Generate(function);

Assert.Contains("/* = \"a * / b\" */", generated, StringComparison.Ordinal, generated);
}

/// <summary>
/// Tests that the text form reads into literals, and that anything else is a name.
/// </summary>
[TestMethod]
public void ParseDefaultValue_ReadsLiteralsAndNames()
{
Assert.IsFalse(((LiteralExpression<bool>)Parameter.ParseDefaultValue("False")!).Value);
Assert.IsTrue(((LiteralExpression<bool>)Parameter.ParseDefaultValue("true")!).Value);
Assert.AreEqual(-3, ((LiteralExpression<int>)Parameter.ParseDefaultValue("-3")!).Value);
Assert.AreEqual(5_000_000_000L, ((LiteralExpression<long>)Parameter.ParseDefaultValue("5000000000")!).Value);
Assert.AreEqual(1.5, ((LiteralExpression<double>)Parameter.ParseDefaultValue("1.5")!).Value);
Assert.AreEqual("say \"hi\"", ((LiteralExpression<string>)Parameter.ParseDefaultValue("\"say \\\"hi\\\"\"")!).Value);
Assert.AreEqual("Color.Red", ((VariableReference)Parameter.ParseDefaultValue("Color.Red")!).Name);
Assert.IsNull(Parameter.ParseDefaultValue(" "));
Assert.IsNull(Parameter.ParseDefaultValue(null));
}

/// <summary>
/// Tests that every text form reads back as the value it was written from.
/// </summary>
/// <param name="text">The text form.</param>
[TestMethod]
[DataRow("true")]
[DataRow("false")]
[DataRow("42")]
[DataRow("-7")]
[DataRow("5000000000")]
[DataRow("1.0")]
[DataRow("0.25")]
[DataRow("\"default\"")]
[DataRow("\"back\\\\slash \\\"quoted\\\"\"")]
[DataRow("Color.Red")]
public void FormatDefaultValue_RoundTrips(string text) =>
Assert.AreEqual(text, Parameter.FormatDefaultValue(Parameter.ParseDefaultValue(text)));

/// <summary>
/// Tests that a document written while defaults were text still reads, including the README's own
/// Python spelling of a flag.
/// </summary>
[TestMethod]
public void AnOldDocumentsTextDefaultReadsAsALiteral()
{
const string Yaml = """
functionDeclaration:
name: describe
parameters:
- name: include_debug
type: bool
isOptional: true
defaultValue: False
""";

FunctionDeclaration function = (FunctionDeclaration)new YamlDeserializer().Deserialize(Yaml)!;

Assert.IsFalse(((LiteralExpression<bool>)function.Parameters[0].DefaultValue!).Value);
}

/// <summary>
/// Tests that a default with no text form survives a round trip through YAML as the node it is.
/// </summary>
[TestMethod]
public void ADefaultWithNoTextFormRoundTripsThroughYaml()
{
FunctionDeclaration function = new("scale") { ReturnType = "int" };
function.Parameters.Add(new Parameter("by", "int")
{
IsOptional = true,
DefaultValue = new BinaryExpression(Literal.Number(2), BinaryOperator.Multiply, Literal.Number(3)),
});

string yaml = new YamlSerializer().Serialize(function)!;
FunctionDeclaration read = (FunctionDeclaration)new YamlDeserializer().Deserialize(yaml)!;

BinaryExpression value = (BinaryExpression)read.Parameters[0].DefaultValue!;
Assert.AreEqual(BinaryOperator.Multiply, value.Operator, yaml);
}

/// <summary>
/// Tests that a clone holds its own copy of the default rather than sharing the original's node.
/// </summary>
[TestMethod]
public void Clone_CopiesTheDefault()
{
Parameter original = new("n", "int") { IsOptional = true, DefaultValue = Literal.Number(1) };

Parameter clone = (Parameter)original.Clone();

Assert.AreNotSame(original.DefaultValue, clone.DefaultValue);
Assert.AreEqual(1, ((LiteralExpression<int>)clone.DefaultValue!).Value);
}
}
2 changes: 1 addition & 1 deletion Coder.Test/Languages/CGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ public void UsingAlias_IsATypedef()
public void OptionalParameter_KeepsItsDefaultAsAComment()
{
FunctionDeclaration function = new("repeat");
function.Parameters.Add(new Parameter("times", "int") { IsOptional = true, DefaultValue = "1" });
function.Parameters.Add(new Parameter("times", "int") { IsOptional = true, DefaultValue = Literal.Number(1) });

StringAssert.Contains(Generator.Generate(function), "void repeat(int times /* = 1 */)", StringComparison.Ordinal);
}
Expand Down
2 changes: 1 addition & 1 deletion Coder.Test/Languages/CompiledExemplar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public static ClassDeclaration Point()

FunctionDeclaration create = new("Point") { Kind = FunctionKind.Constructor };
create.Parameters.Add(new Parameter("x", "int"));
create.Parameters.Add(new Parameter("y", "int") { IsOptional = true, DefaultValue = "0" });
create.Parameters.Add(new Parameter("y", "int") { IsOptional = true, DefaultValue = Literal.Number(0) });
create.Initialisers.Add(new MemberInitialiser("x") { Value = new VariableReference("x") });
create.Initialisers.Add(new MemberInitialiser("y") { Value = new VariableReference("y") });
point.Members.Add(create);
Expand Down
2 changes: 1 addition & 1 deletion Coder.Test/Languages/GoGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ public void NestedType_IsWrittenBesideTheOneDeclaringIt()
public void OptionalParameter_KeepsItsDefaultAsAComment()
{
FunctionDeclaration function = new("greet");
function.Parameters.Add(new Parameter("times", "int") { IsOptional = true, DefaultValue = "1" });
function.Parameters.Add(new Parameter("times", "int") { IsOptional = true, DefaultValue = Literal.Number(1) });

StringAssert.Contains(Generator.Generate(function), "(times int /* = 1 */)", StringComparison.Ordinal);
}
Expand Down
2 changes: 1 addition & 1 deletion Coder.Test/Languages/JavaScriptGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public void Parameters_DropTypesAndKeepDefaults()
{
FunctionDeclaration function = new("greet");
function.Parameters.Add(new Parameter("name", "str"));
function.Parameters.Add(new Parameter("loud", "bool") { IsOptional = true, DefaultValue = "false" });
function.Parameters.Add(new Parameter("loud", "bool") { IsOptional = true, DefaultValue = Literal.Bool(false) });

string code = Generator.Generate(function);

Expand Down
6 changes: 3 additions & 3 deletions Coder.Test/Serialization/YamlSerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public void Serialize_FunctionDeclaration_GeneratesCorrectYaml()
Parameters =
[
new Parameter("param1", "int"),
new Parameter("param2", "string") { IsOptional = true, DefaultValue = "\"default\"" }
new Parameter("param2", "string") { IsOptional = true, DefaultValue = Literal.Text("default") }
]
};
funcDecl.Body.Add(new ReturnStatement(42));
Expand Down Expand Up @@ -57,7 +57,7 @@ public void SerializeAndDeserialize_FunctionDeclaration_RoundTripsCorrectly()
Parameters =
[
new Parameter("param1", "int"),
new Parameter("param2", "string") { IsOptional = true, DefaultValue = "\"default\"" }
new Parameter("param2", "string") { IsOptional = true, DefaultValue = Literal.Text("default") }
]
};
originalFuncDecl.Body.Add(new ReturnStatement(42));
Expand All @@ -80,7 +80,7 @@ public void SerializeAndDeserialize_FunctionDeclaration_RoundTripsCorrectly()
Assert.AreEqual(originalFuncDecl.Parameters[0].Name, deserializedFunc.Parameters[0].Name);
Assert.AreEqual(originalFuncDecl.Parameters[0].Type, deserializedFunc.Parameters[0].Type);
Assert.AreEqual(originalFuncDecl.Parameters[1].IsOptional, deserializedFunc.Parameters[1].IsOptional);
Assert.AreEqual(originalFuncDecl.Parameters[1].DefaultValue, deserializedFunc.Parameters[1].DefaultValue);
Assert.AreEqual("default", ((LiteralExpression<string>)deserializedFunc.Parameters[1].DefaultValue!).Value);
Assert.AreEqual(originalFuncDecl.Body.Count, deserializedFunc.Body.Count);

Assert.IsInstanceOfType<ReturnStatement>(deserializedFunc.Body[0]);
Expand Down
Loading
Loading