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
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ source in four target languages. The solution uses:
### Key Files

- `Coder/Ast/*.cs` — one file per node type. `AstNode` is the base; `AstCompositeNode` adds a
keyed child dictionary; `Expression` marks the nodes that evaluate to a value.
keyed child dictionary; `Expression` marks the nodes that evaluate to a value. `Visibility` is an
enumeration rather than the modifier's text, because each generator spells it differently — or,
in Python's case, not at all — and `IHasVisibility` is how a generator reads it off a member
without switching on which kind of member it is.
- `Coder/Languages/LanguageGeneratorBase.cs` — the emitters every generator shares.
- `Coder/Languages/StandardLanguageGenerator.cs` — owns the node dispatch, so a derived
generator supplies only the syntax its language does not share. `CSharpGenerator` deliberately
Expand Down
45 changes: 35 additions & 10 deletions Coder.Graph/AstFields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,18 @@
public static class AstFields
{
/// <summary>
/// The access modifiers a class declaration offers.
/// The visibilities a declaration offers.
/// </summary>
private static readonly IReadOnlyList<AstFieldChoice> AccessModifiers =
/// <remarks>
/// Built from the enumeration so a visibility the AST gains appears in the inspector without
/// anyone remembering to list it here. <see cref="Visibility.Unspecified"/> is labelled for what
/// it means rather than by its name: it is not a fifth modifier, it is the absence of one.
/// </remarks>
private static readonly IReadOnlyList<AstFieldChoice> Visibilities =
[
new("public", "public"),
new("internal", "internal"),
new("protected", "protected"),
new("private", "private"),
.. Enum.GetValues<Visibility>().Select(visibility => new AstFieldChoice(
visibility.ToString(),
visibility == Visibility.Unspecified ? "(language default)" : visibility.ToString().ToLowerInvariant())),
];

/// <summary>
Expand All @@ -107,13 +111,20 @@
[
new("Name", AstFieldKind.Text, classDecl.Name ?? string.Empty),
new("BaseType", AstFieldKind.Text, classDecl.BaseType ?? string.Empty),
new("Access", AstFieldKind.Choice, classDecl.AccessModifier ?? "public", AccessModifiers),
new("Visibility", AstFieldKind.Choice, classDecl.Visibility.ToString(), Visibilities),

Check warning on line 114 in Coder.Graph/AstFields.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using this literal 'Visibility' 6 times.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLL3wYhKJKaC1-cN3&open=AaCBLL3wYhKJKaC1-cN3&pullRequest=32
],

FunctionDeclaration function =>
[
new("Name", AstFieldKind.Text, function.Name ?? string.Empty),
new("ReturnType", AstFieldKind.Text, function.ReturnType ?? string.Empty),
new("Visibility", AstFieldKind.Choice, function.Visibility.ToString(), Visibilities),
],

EntryPoint entryPoint =>
[
new("Arguments", AstFieldKind.Flag, Spell(entryPoint.AcceptsArguments)),
new("ExitCode", AstFieldKind.Flag, Spell(entryPoint.ReturnsExitCode)),
],

Parameter parameter =>
Expand All @@ -130,7 +141,7 @@
new("Type", AstFieldKind.Text, varDecl.Type ?? string.Empty),
new("Constant", AstFieldKind.Flag, Spell(varDecl.IsConstant)),
new("Inferred", AstFieldKind.Flag, Spell(varDecl.IsTypeInferred)),
new("Access", AstFieldKind.Text, varDecl.AccessModifier ?? string.Empty),
new("Visibility", AstFieldKind.Choice, varDecl.Visibility.ToString(), Visibilities),
],

VariableReference varRef =>
Expand Down Expand Up @@ -207,10 +218,18 @@
{
(ClassDeclaration classDecl, "Name") => Assign(() => classDecl.Name = OrNull(value)),
(ClassDeclaration classDecl, "BaseType") => Assign(() => classDecl.BaseType = OrNull(value)),
(ClassDeclaration classDecl, "Access") => Assign(() => classDecl.AccessModifier = OrNull(value)),
(ClassDeclaration classDecl, "Visibility") =>
TryParseVisibility(value, out Visibility classVisibility) && Assign(() => classDecl.Visibility = classVisibility),

(FunctionDeclaration function, "Name") => Assign(() => function.Name = OrNull(value)),
(FunctionDeclaration function, "ReturnType") => Assign(() => function.ReturnType = OrNull(value)),
(FunctionDeclaration function, "Visibility") =>
TryParseVisibility(value, out Visibility functionVisibility) && Assign(() => function.Visibility = functionVisibility),

(EntryPoint entryPoint, "Arguments") =>
TryParseBool(value, out bool acceptsArguments) && Assign(() => entryPoint.AcceptsArguments = acceptsArguments),
(EntryPoint entryPoint, "ExitCode") =>
TryParseBool(value, out bool returnsExitCode) && Assign(() => entryPoint.ReturnsExitCode = returnsExitCode),

(Parameter parameter, "Name") => Assign(() => parameter.Name = OrNull(value)),
(Parameter parameter, "Type") => Assign(() => parameter.Type = OrNull(value)),
Expand All @@ -221,7 +240,8 @@
(VariableDeclaration varDecl, "Type") => Assign(() => varDecl.Type = OrNull(value)),
(VariableDeclaration varDecl, "Constant") => TryParseBool(value, out bool constant) && Assign(() => varDecl.IsConstant = constant),
(VariableDeclaration varDecl, "Inferred") => TryParseBool(value, out bool inferred) && Assign(() => varDecl.IsTypeInferred = inferred),
(VariableDeclaration varDecl, "Access") => Assign(() => varDecl.AccessModifier = OrNull(value)),
(VariableDeclaration varDecl, "Visibility") =>
TryParseVisibility(value, out Visibility varVisibility) && Assign(() => varDecl.Visibility = varVisibility),

(VariableReference varRef, "Name") => value.Length > 0 && Assign(() => varRef.Name = value),

Expand Down Expand Up @@ -316,6 +336,11 @@

private static bool TryParseBool(string value, out bool result) => bool.TryParse(value, out result);

// Case-insensitively, so a document hand-edited with "public" reads back the same as the
// inspector's own "Public".
private static bool TryParseVisibility(string value, out Visibility result) =>
Enum.TryParse(value, ignoreCase: true, out result);

private static string Spell(bool value) => value ? "true" : "false";

private static string Spell(int value) => value.ToString(CultureInfo.InvariantCulture);
Expand Down
2 changes: 2 additions & 0 deletions Coder.Graph/AstNodeCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ public static class AstNodeCatalog
new("Declarations", "Function", () => new FunctionDeclaration("newFunction") { ReturnType = "void" }),
new("Declarations", "Parameter", () => new Parameter("value", "int")),
new("Declarations", "Variable", () => new VariableDeclaration("value", "int")),
new("Declarations", "Constant", () => new VariableDeclaration("VALUE", "int", Literal.Number(0)) { IsConstant = true }),
new("Declarations", "Entry point", () => new EntryPoint()),

new("Statements", "Return", () => new ReturnStatement()),
.. Enum.GetValues<AssignmentOperator>().Select(op => new AstNodeTemplate(
Expand Down
21 changes: 19 additions & 2 deletions Coder.Graph/AstSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public static class AstSchema
{
ClassDeclaration => [MembersSlot],
FunctionDeclaration => [ParametersSlot, BodySlot],
EntryPoint => [BodySlot],
ReturnStatement => [ExpressionSlot],
BinaryExpression => [LeftSlot, RightSlot],
UnaryExpression => [OperandSlot],
Expand Down Expand Up @@ -87,6 +88,7 @@ public static IReadOnlyList<AstNode> ChildrenOf(AstNode node, AstSlot slot)
(ClassDeclaration classDecl, "Members") => [.. classDecl.Members],
(FunctionDeclaration function, "Parameters") => [.. function.Parameters],
(FunctionDeclaration function, "Body") => [.. function.Body],
(EntryPoint entryPoint, "Body") => [.. entryPoint.Body],
_ when SlotsOf(node).Contains(slot) => [],
_ => throw new ArgumentException($"{node.GetNodeTypeName()} has no slot named '{slot.Name}'.", nameof(slot)),
};
Expand Down Expand Up @@ -161,6 +163,10 @@ public static bool TryAttachAt(AstNode parent, AstSlot slot, int index, AstNode
function.Body.Add(child);
return true;

case (EntryPoint entryPoint, "Body"):
entryPoint.Body.Add(child);
return true;

case (ClassDeclaration classDecl, "Members"):
classDecl.Members.Add(child);
return true;
Expand Down Expand Up @@ -190,6 +196,10 @@ private static bool TryReplaceAt(AstNode parent, AstSlot slot, int index, AstNod
function.Body[index] = child;
return true;

case (EntryPoint entryPoint, "Body"):
entryPoint.Body[index] = child;
return true;

case (ClassDeclaration classDecl, "Members"):
classDecl.Members[index] = child;
return true;
Expand Down Expand Up @@ -266,6 +276,10 @@ public static bool TryDetachAt(AstNode parent, AstSlot slot, int index)
function.Body.RemoveAt(index);
return true;

case (EntryPoint entryPoint, "Body") when index < entryPoint.Body.Count:
entryPoint.Body.RemoveAt(index);
return true;

case (ClassDeclaration classDecl, "Members") when index < classDecl.Members.Count:
classDecl.Members.RemoveAt(index);
return true;
Expand Down Expand Up @@ -332,8 +346,10 @@ public static bool Accepts(AstSlot slot, AstNode candidate)
{
AstSlotKind.Parameter => candidate is Parameter,
AstSlotKind.Expression => IsExpression(candidate),
AstSlotKind.Statement => candidate is not Parameter,
AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or ClassDeclaration,
// A parameter is not a statement, and neither is an entry point: a program starts
// running at one, so it belongs to a class or to the document rather than inside a body.
AstSlotKind.Statement => candidate is not (Parameter or EntryPoint),
AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or ClassDeclaration or EntryPoint,
_ => false,
};
}
Expand Down Expand Up @@ -367,6 +383,7 @@ public static string Describe(AstNode node)
{
ClassDeclaration classDecl => $"class {classDecl.Name ?? "<unnamed>"}",
FunctionDeclaration function => $"function {function.Name ?? "<unnamed>"}",
EntryPoint => "entry point",
Parameter parameter => $"param {parameter.Name ?? "<unnamed>"}",
ReturnStatement => "return",
BinaryExpression binary => $"binary {SpellOrName(binary.Operator)}",
Expand Down
10 changes: 5 additions & 5 deletions Coder.Test/Ast/ClassDeclarationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,13 @@ public void CSharp_GeneratesAClass()
}

/// <summary>
/// Tests that the access modifier a class carries is the one emitted, and that a class without one
/// Tests that the visibility a class carries is the one emitted, and that a class without one
/// is public rather than nothing at all.
/// </summary>
[TestMethod]
public void CSharp_UsesTheDeclaredAccessModifier()
public void CSharp_UsesTheDeclaredVisibility()
{
ClassDeclaration declaration = new("Point") { AccessModifier = "internal" };
ClassDeclaration declaration = new("Point") { Visibility = Visibility.Internal };

StringAssert.StartsWith(new CSharpGenerator().Generate(declaration), "internal class Point", StringComparison.Ordinal);
StringAssert.StartsWith(new CSharpGenerator().Generate(new ClassDeclaration("Point")), "public class Point", StringComparison.Ordinal);
Expand Down Expand Up @@ -252,13 +252,13 @@ public void Yaml_RoundTripsAClass()
public void Yaml_RoundTripsANestedClass()
{
ClassDeclaration outer = new("Outer");
outer.Members.Add(new ClassDeclaration("Inner") { AccessModifier = "private" });
outer.Members.Add(new ClassDeclaration("Inner") { Visibility = Visibility.Private });

string yaml = new YamlSerializer().Serialize(outer);
ClassDeclaration restored = (ClassDeclaration)new YamlDeserializer().Deserialize(yaml)!;

Assert.IsInstanceOfType<ClassDeclaration>(restored.Members.Single());
Assert.AreEqual("Inner", ((ClassDeclaration)restored.Members[0]).Name);
Assert.AreEqual("private", ((ClassDeclaration)restored.Members[0]).AccessModifier);
Assert.AreEqual(Visibility.Private, ((ClassDeclaration)restored.Members[0]).Visibility);
}
}
175 changes: 175 additions & 0 deletions Coder.Test/Ast/ConstantDeclarationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Test.Ast;

using ktsu.Coder.Ast;
using ktsu.Coder.Graph;
using ktsu.Coder.Languages;
using ktsu.Coder.Serialization;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests for a constant: a <see cref="VariableDeclaration"/> marked
/// <see cref="VariableDeclaration.IsConstant"/> and initialised from a literal.
/// </summary>
/// <remarks>
/// A constant is spelled two ways in each language that has one — as a local and as a class member —
/// and they are not the same spelling, so both are covered here rather than only the local one.
/// </remarks>
[TestClass]
public class ConstantDeclarationTests
{
/// <summary>
/// Builds a constant with a literal value.
/// </summary>
/// <returns>The declaration.</returns>
private static VariableDeclaration SampleConstant() =>
new("MAX", "int", Literal.Number(10)) { IsConstant = true };

/// <summary>
/// Builds a class holding one constant member.
/// </summary>
/// <returns>The class.</returns>
private static ClassDeclaration SampleClass()
{
ClassDeclaration declaration = new("Limits");
declaration.Members.Add(SampleConstant());
return declaration;
}

/// <summary>
/// Tests that a clone stays constant, since a copy that quietly became writable would be a copy
/// of something else.
/// </summary>
[TestMethod]
public void Clone_StaysConstant()
{
VariableDeclaration clone = (VariableDeclaration)SampleConstant().Clone();

Assert.IsTrue(clone.IsConstant);
Assert.AreEqual(10, ((LiteralExpression<int>)clone.InitialValue!).Value);
}

/// <summary>
/// Tests that C# writes <c>const</c> in front of the declaration, with the type a C# constant has
/// to name.
/// </summary>
[TestMethod]
public void CSharp_WritesConst()
{
StringAssert.Contains(new CSharpGenerator().Generate(SampleConstant()), "const int MAX = 10;", StringComparison.Ordinal);
StringAssert.Contains(new CSharpGenerator().Generate(SampleClass()), "const int MAX = 10;", StringComparison.Ordinal);

Check warning on line 61 in Coder.Test/Ast/ConstantDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLLzRYhKJKaC1-cNu&open=AaCBLLzRYhKJKaC1-cNu&pullRequest=32
}

/// <summary>
/// Tests that C++ writes <c>const</c> for a local and <c>static constexpr</c> for a class member,
/// which is the spelling that gives the class one compile-time value rather than one per object.
/// </summary>
[TestMethod]
public void Cpp_WritesConstexprForAMember()
{
StringAssert.Contains(new CppGenerator().Generate(SampleConstant()), "const int MAX = 10;", StringComparison.Ordinal);

Check warning on line 71 in Coder.Test/Ast/ConstantDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLLzRYhKJKaC1-cNv&open=AaCBLLzRYhKJKaC1-cNv&pullRequest=32
StringAssert.Contains(new CppGenerator().Generate(SampleClass()), "static constexpr int MAX = 10;", StringComparison.Ordinal);
}

/// <summary>
/// Tests that a constant member with nothing to initialise it stays a plain <c>const</c>, since
/// <c>constexpr</c> without an initialiser does not compile.
/// </summary>
[TestMethod]
public void Cpp_LeavesAnUninitialisedMemberAsConst()
{
ClassDeclaration declaration = new("Limits");
declaration.Members.Add(new VariableDeclaration("MAX", "int") { IsConstant = true });

string code = new CppGenerator().Generate(declaration);

StringAssert.Contains(code, "const int MAX;", StringComparison.Ordinal);

Check warning on line 87 in Coder.Test/Ast/ConstantDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLLzRYhKJKaC1-cNx&open=AaCBLLzRYhKJKaC1-cNx&pullRequest=32
Assert.IsFalse(code.Contains("constexpr", StringComparison.Ordinal));
}

/// <summary>
/// Tests that JavaScript writes <c>const</c> for a local and <c>static</c> for a class member,
/// since <c>const</c> declares a binding in a scope and a class body is not one.
/// </summary>
[TestMethod]
public void JavaScript_WritesStaticForAMember()
{
StringAssert.Contains(new JavaScriptGenerator().Generate(SampleConstant()), "const MAX = 10;", StringComparison.Ordinal);

Check warning on line 98 in Coder.Test/Ast/ConstantDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLLzRYhKJKaC1-cNy&open=AaCBLLzRYhKJKaC1-cNy&pullRequest=32
StringAssert.Contains(new JavaScriptGenerator().Generate(SampleClass()), "static MAX = 10;", StringComparison.Ordinal);

Check warning on line 99 in Coder.Test/Ast/ConstantDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLLzRYhKJKaC1-cNz&open=AaCBLLzRYhKJKaC1-cNz&pullRequest=32
}

/// <summary>
/// Tests that a private constant member is spelled with both, since the two are independent.
/// </summary>
[TestMethod]
public void JavaScript_CombinesStaticWithAPrivateName()
{
ClassDeclaration declaration = new("Limits");
declaration.Members.Add(new VariableDeclaration("MAX", "int", Literal.Number(10))
{
IsConstant = true,
Visibility = Visibility.Private,
});

StringAssert.Contains(new JavaScriptGenerator().Generate(declaration), "static #MAX = 10;", StringComparison.Ordinal);

Check warning on line 115 in Coder.Test/Ast/ConstantDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLLzRYhKJKaC1-cN0&open=AaCBLLzRYhKJKaC1-cN0&pullRequest=32
}

/// <summary>
/// Tests that Python emits an ordinary assignment, since it has no constant declaration and the
/// upper-case naming that stands in for one is a convention about the identifier rather than
/// something the declaration can say.
/// </summary>
[TestMethod]
public void Python_EmitsAnOrdinaryAssignment()
{
string code = new PythonGenerator().Generate(SampleConstant());

StringAssert.Contains(code, "MAX = 10", StringComparison.Ordinal);

Check warning on line 128 in Coder.Test/Ast/ConstantDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLLzRYhKJKaC1-cN1&open=AaCBLLzRYhKJKaC1-cN1&pullRequest=32
Assert.IsFalse(code.Contains("const", StringComparison.Ordinal));
}

/// <summary>
/// Tests that a constant survives a round trip through YAML, value and all.
/// </summary>
[TestMethod]
public void Yaml_RoundTripsAConstant()
{
string yaml = new YamlSerializer().Serialize(SampleConstant());
VariableDeclaration restored = (VariableDeclaration)new YamlDeserializer().Deserialize(yaml)!;

Assert.IsTrue(restored.IsConstant);
Assert.AreEqual("MAX", restored.Name);
Assert.AreEqual(10, ((LiteralExpression<int>)restored.InitialValue!).Value);
}

/// <summary>
/// Tests that the palette offers a constant already holding a literal, so it is a node the user
/// edits rather than one they have to wire a value into first.
/// </summary>
[TestMethod]
public void Catalog_OffersAConstantHoldingALiteral()
{
AstNode created = AstNodeCatalog.Templates
.Single(template => string.Equals(template.Label, "Constant", StringComparison.Ordinal))
.Create();

VariableDeclaration constant = (VariableDeclaration)created;

Assert.IsTrue(constant.IsConstant);
Assert.IsInstanceOfType<LiteralExpression<int>>(constant.InitialValue);
}

/// <summary>
/// Tests that a constant with nothing in it is reported rather than generated, since a constant
/// is the one declaration whose value is not optional.
/// </summary>
[TestMethod]
public void Graph_ReportsAConstantWithNoValue()
{
VariableDeclaration constant = new("MAX", "int") { IsConstant = true };
AstGraph graph = new(constant);

Assert.IsTrue(graph.Validate().Any(problem => ReferenceEquals(problem.Node, constant)));

Check warning on line 173 in Coder.Test/Ast/ConstantDeclarationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaCBLLzRYhKJKaC1-cN2&open=AaCBLLzRYhKJKaC1-cN2&pullRequest=32
}
}
Loading