Skip to content

Commit cbfb4d1

Browse files
committed
[major] Model visibility, constants and entry points in the AST
Closes three gaps where the AST could not say something every target language can spell. Visibility (#25) becomes an enumeration — Public, Protected, Internal, Private, and Unspecified for "however the language would write it anyway" — carried by ClassDeclaration, FunctionDeclaration and VariableDeclaration through IHasVisibility. It replaces the free-text AccessModifier that ClassDeclaration and VariableDeclaration carried, which could only ever be right for whichever language it was typed for. Each generator spells it its own way: C# writes the keyword, C++ groups members under access labels, JavaScript gives a private member the # prefix that is its own private syntax, and Python writes nothing at all, because its leading-underscore convention renames the declaration and would leave every reference to it naming something that no longer exists. Documents written with accessModifier still load. Constants (#26) are a VariableDeclaration marked IsConstant with a literal value, and now come out right wherever they sit: C++ writes static constexpr for a class member rather than a per-instance const, JavaScript writes static for one rather than a const that is a syntax error in a class body, and the palette offers a constant already holding a literal. EntryPoint (#27) is a node of its own rather than a function named main, since only some languages spell the entry point as a function at all. It carries the two things that vary — whether the program reads its arguments and whether it returns an exit code — and each generator writes what its language looks for: C#'s static Main, C++'s free int main, Python's main with the __main__ guard and the import its arguments need, and JavaScript's main with the call that runs it. The schema, the inspector, the palette and the YAML round trip carry all three, with 41 tests over the four generators and both directions of serialization. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMNNvzNnuLErq96R4NjYUi
1 parent 4b62a75 commit cbfb4d1

23 files changed

Lines changed: 1397 additions & 64 deletions

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ source in four target languages. The solution uses:
4343
### Key Files
4444

4545
- `Coder/Ast/*.cs` — one file per node type. `AstNode` is the base; `AstCompositeNode` adds a
46-
keyed child dictionary; `Expression` marks the nodes that evaluate to a value.
46+
keyed child dictionary; `Expression` marks the nodes that evaluate to a value. `Visibility` is an
47+
enumeration rather than the modifier's text, because each generator spells it differently — or,
48+
in Python's case, not at all — and `IHasVisibility` is how a generator reads it off a member
49+
without switching on which kind of member it is.
4750
- `Coder/Languages/LanguageGeneratorBase.cs` — the emitters every generator shares.
4851
- `Coder/Languages/StandardLanguageGenerator.cs` — owns the node dispatch, so a derived
4952
generator supplies only the syntax its language does not share. `CSharpGenerator` deliberately

Coder.Graph/AstFields.cs

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,18 @@ public AstField(string name, AstFieldKind kind, string value)
8282
public static class AstFields
8383
{
8484
/// <summary>
85-
/// The access modifiers a class declaration offers.
85+
/// The visibilities a declaration offers.
8686
/// </summary>
87-
private static readonly IReadOnlyList<AstFieldChoice> AccessModifiers =
87+
/// <remarks>
88+
/// Built from the enumeration so a visibility the AST gains appears in the inspector without
89+
/// anyone remembering to list it here. <see cref="Visibility.Unspecified"/> is labelled for what
90+
/// it means rather than by its name: it is not a fifth modifier, it is the absence of one.
91+
/// </remarks>
92+
private static readonly IReadOnlyList<AstFieldChoice> Visibilities =
8893
[
89-
new("public", "public"),
90-
new("internal", "internal"),
91-
new("protected", "protected"),
92-
new("private", "private"),
94+
.. Enum.GetValues<Visibility>().Select(visibility => new AstFieldChoice(
95+
visibility.ToString(),
96+
visibility == Visibility.Unspecified ? "(language default)" : visibility.ToString().ToLowerInvariant())),
9397
];
9498

9599
/// <summary>
@@ -107,13 +111,20 @@ public static IReadOnlyList<AstField> Of(AstNode node)
107111
[
108112
new("Name", AstFieldKind.Text, classDecl.Name ?? string.Empty),
109113
new("BaseType", AstFieldKind.Text, classDecl.BaseType ?? string.Empty),
110-
new("Access", AstFieldKind.Choice, classDecl.AccessModifier ?? "public", AccessModifiers),
114+
new("Visibility", AstFieldKind.Choice, classDecl.Visibility.ToString(), Visibilities),
111115
],
112116

113117
FunctionDeclaration function =>
114118
[
115119
new("Name", AstFieldKind.Text, function.Name ?? string.Empty),
116120
new("ReturnType", AstFieldKind.Text, function.ReturnType ?? string.Empty),
121+
new("Visibility", AstFieldKind.Choice, function.Visibility.ToString(), Visibilities),
122+
],
123+
124+
EntryPoint entryPoint =>
125+
[
126+
new("Arguments", AstFieldKind.Flag, Spell(entryPoint.AcceptsArguments)),
127+
new("ExitCode", AstFieldKind.Flag, Spell(entryPoint.ReturnsExitCode)),
117128
],
118129

119130
Parameter parameter =>
@@ -130,7 +141,7 @@ public static IReadOnlyList<AstField> Of(AstNode node)
130141
new("Type", AstFieldKind.Text, varDecl.Type ?? string.Empty),
131142
new("Constant", AstFieldKind.Flag, Spell(varDecl.IsConstant)),
132143
new("Inferred", AstFieldKind.Flag, Spell(varDecl.IsTypeInferred)),
133-
new("Access", AstFieldKind.Text, varDecl.AccessModifier ?? string.Empty),
144+
new("Visibility", AstFieldKind.Choice, varDecl.Visibility.ToString(), Visibilities),
134145
],
135146

136147
VariableReference varRef =>
@@ -207,10 +218,18 @@ public static bool TryWrite(AstNode node, string fieldName, string value)
207218
{
208219
(ClassDeclaration classDecl, "Name") => Assign(() => classDecl.Name = OrNull(value)),
209220
(ClassDeclaration classDecl, "BaseType") => Assign(() => classDecl.BaseType = OrNull(value)),
210-
(ClassDeclaration classDecl, "Access") => Assign(() => classDecl.AccessModifier = OrNull(value)),
221+
(ClassDeclaration classDecl, "Visibility") =>
222+
TryParseVisibility(value, out Visibility classVisibility) && Assign(() => classDecl.Visibility = classVisibility),
211223

212224
(FunctionDeclaration function, "Name") => Assign(() => function.Name = OrNull(value)),
213225
(FunctionDeclaration function, "ReturnType") => Assign(() => function.ReturnType = OrNull(value)),
226+
(FunctionDeclaration function, "Visibility") =>
227+
TryParseVisibility(value, out Visibility functionVisibility) && Assign(() => function.Visibility = functionVisibility),
228+
229+
(EntryPoint entryPoint, "Arguments") =>
230+
TryParseBool(value, out bool acceptsArguments) && Assign(() => entryPoint.AcceptsArguments = acceptsArguments),
231+
(EntryPoint entryPoint, "ExitCode") =>
232+
TryParseBool(value, out bool returnsExitCode) && Assign(() => entryPoint.ReturnsExitCode = returnsExitCode),
214233

215234
(Parameter parameter, "Name") => Assign(() => parameter.Name = OrNull(value)),
216235
(Parameter parameter, "Type") => Assign(() => parameter.Type = OrNull(value)),
@@ -221,7 +240,8 @@ public static bool TryWrite(AstNode node, string fieldName, string value)
221240
(VariableDeclaration varDecl, "Type") => Assign(() => varDecl.Type = OrNull(value)),
222241
(VariableDeclaration varDecl, "Constant") => TryParseBool(value, out bool constant) && Assign(() => varDecl.IsConstant = constant),
223242
(VariableDeclaration varDecl, "Inferred") => TryParseBool(value, out bool inferred) && Assign(() => varDecl.IsTypeInferred = inferred),
224-
(VariableDeclaration varDecl, "Access") => Assign(() => varDecl.AccessModifier = OrNull(value)),
243+
(VariableDeclaration varDecl, "Visibility") =>
244+
TryParseVisibility(value, out Visibility varVisibility) && Assign(() => varDecl.Visibility = varVisibility),
225245

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

@@ -316,6 +336,11 @@ private static bool TryParseDouble(string value, out double result) =>
316336

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

339+
// Case-insensitively, so a document hand-edited with "public" reads back the same as the
340+
// inspector's own "Public".
341+
private static bool TryParseVisibility(string value, out Visibility result) =>
342+
Enum.TryParse(value, ignoreCase: true, out result);
343+
319344
private static string Spell(bool value) => value ? "true" : "false";
320345

321346
private static string Spell(int value) => value.ToString(CultureInfo.InvariantCulture);

Coder.Graph/AstNodeCatalog.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ public static class AstNodeCatalog
4848
new("Declarations", "Function", () => new FunctionDeclaration("newFunction") { ReturnType = "void" }),
4949
new("Declarations", "Parameter", () => new Parameter("value", "int")),
5050
new("Declarations", "Variable", () => new VariableDeclaration("value", "int")),
51+
new("Declarations", "Constant", () => new VariableDeclaration("VALUE", "int", Literal.Number(0)) { IsConstant = true }),
52+
new("Declarations", "Entry point", () => new EntryPoint()),
5153

5254
new("Statements", "Return", () => new ReturnStatement()),
5355
.. Enum.GetValues<AssignmentOperator>().Select(op => new AstNodeTemplate(

Coder.Graph/AstSchema.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public static class AstSchema
4545
{
4646
ClassDeclaration => [MembersSlot],
4747
FunctionDeclaration => [ParametersSlot, BodySlot],
48+
EntryPoint => [BodySlot],
4849
ReturnStatement => [ExpressionSlot],
4950
BinaryExpression => [LeftSlot, RightSlot],
5051
UnaryExpression => [OperandSlot],
@@ -87,6 +88,7 @@ public static IReadOnlyList<AstNode> ChildrenOf(AstNode node, AstSlot slot)
8788
(ClassDeclaration classDecl, "Members") => [.. classDecl.Members],
8889
(FunctionDeclaration function, "Parameters") => [.. function.Parameters],
8990
(FunctionDeclaration function, "Body") => [.. function.Body],
91+
(EntryPoint entryPoint, "Body") => [.. entryPoint.Body],
9092
_ when SlotsOf(node).Contains(slot) => [],
9193
_ => throw new ArgumentException($"{node.GetNodeTypeName()} has no slot named '{slot.Name}'.", nameof(slot)),
9294
};
@@ -161,6 +163,10 @@ public static bool TryAttachAt(AstNode parent, AstSlot slot, int index, AstNode
161163
function.Body.Add(child);
162164
return true;
163165

166+
case (EntryPoint entryPoint, "Body"):
167+
entryPoint.Body.Add(child);
168+
return true;
169+
164170
case (ClassDeclaration classDecl, "Members"):
165171
classDecl.Members.Add(child);
166172
return true;
@@ -190,6 +196,10 @@ private static bool TryReplaceAt(AstNode parent, AstSlot slot, int index, AstNod
190196
function.Body[index] = child;
191197
return true;
192198

199+
case (EntryPoint entryPoint, "Body"):
200+
entryPoint.Body[index] = child;
201+
return true;
202+
193203
case (ClassDeclaration classDecl, "Members"):
194204
classDecl.Members[index] = child;
195205
return true;
@@ -266,6 +276,10 @@ public static bool TryDetachAt(AstNode parent, AstSlot slot, int index)
266276
function.Body.RemoveAt(index);
267277
return true;
268278

279+
case (EntryPoint entryPoint, "Body") when index < entryPoint.Body.Count:
280+
entryPoint.Body.RemoveAt(index);
281+
return true;
282+
269283
case (ClassDeclaration classDecl, "Members") when index < classDecl.Members.Count:
270284
classDecl.Members.RemoveAt(index);
271285
return true;
@@ -332,8 +346,10 @@ public static bool Accepts(AstSlot slot, AstNode candidate)
332346
{
333347
AstSlotKind.Parameter => candidate is Parameter,
334348
AstSlotKind.Expression => IsExpression(candidate),
335-
AstSlotKind.Statement => candidate is not Parameter,
336-
AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or ClassDeclaration,
349+
// A parameter is not a statement, and neither is an entry point: a program starts
350+
// running at one, so it belongs to a class or to the document rather than inside a body.
351+
AstSlotKind.Statement => candidate is not (Parameter or EntryPoint),
352+
AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or ClassDeclaration or EntryPoint,
337353
_ => false,
338354
};
339355
}
@@ -367,6 +383,7 @@ public static string Describe(AstNode node)
367383
{
368384
ClassDeclaration classDecl => $"class {classDecl.Name ?? "<unnamed>"}",
369385
FunctionDeclaration function => $"function {function.Name ?? "<unnamed>"}",
386+
EntryPoint => "entry point",
370387
Parameter parameter => $"param {parameter.Name ?? "<unnamed>"}",
371388
ReturnStatement => "return",
372389
BinaryExpression binary => $"binary {SpellOrName(binary.Operator)}",

Coder.Test/Ast/ClassDeclarationTests.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,13 @@ public void CSharp_GeneratesAClass()
137137
}
138138

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

148148
StringAssert.StartsWith(new CSharpGenerator().Generate(declaration), "internal class Point", StringComparison.Ordinal);
149149
StringAssert.StartsWith(new CSharpGenerator().Generate(new ClassDeclaration("Point")), "public class Point", StringComparison.Ordinal);
@@ -252,13 +252,13 @@ public void Yaml_RoundTripsAClass()
252252
public void Yaml_RoundTripsANestedClass()
253253
{
254254
ClassDeclaration outer = new("Outer");
255-
outer.Members.Add(new ClassDeclaration("Inner") { AccessModifier = "private" });
255+
outer.Members.Add(new ClassDeclaration("Inner") { Visibility = Visibility.Private });
256256

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

260260
Assert.IsInstanceOfType<ClassDeclaration>(restored.Members.Single());
261261
Assert.AreEqual("Inner", ((ClassDeclaration)restored.Members[0]).Name);
262-
Assert.AreEqual("private", ((ClassDeclaration)restored.Members[0]).AccessModifier);
262+
Assert.AreEqual(Visibility.Private, ((ClassDeclaration)restored.Members[0]).Visibility);
263263
}
264264
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
2+
3+
namespace ktsu.Coder.Test.Ast;
4+
5+
using ktsu.Coder.Ast;
6+
using ktsu.Coder.Graph;
7+
using ktsu.Coder.Languages;
8+
using ktsu.Coder.Serialization;
9+
using Microsoft.VisualStudio.TestTools.UnitTesting;
10+
11+
/// <summary>
12+
/// Tests for a constant: a <see cref="VariableDeclaration"/> marked
13+
/// <see cref="VariableDeclaration.IsConstant"/> and initialised from a literal.
14+
/// </summary>
15+
/// <remarks>
16+
/// A constant is spelled two ways in each language that has one — as a local and as a class member —
17+
/// and they are not the same spelling, so both are covered here rather than only the local one.
18+
/// </remarks>
19+
[TestClass]
20+
public class ConstantDeclarationTests
21+
{
22+
/// <summary>
23+
/// Builds a constant with a literal value.
24+
/// </summary>
25+
/// <returns>The declaration.</returns>
26+
private static VariableDeclaration SampleConstant() =>
27+
new("MAX", "int", Literal.Number(10)) { IsConstant = true };
28+
29+
/// <summary>
30+
/// Builds a class holding one constant member.
31+
/// </summary>
32+
/// <returns>The class.</returns>
33+
private static ClassDeclaration SampleClass()
34+
{
35+
ClassDeclaration declaration = new("Limits");
36+
declaration.Members.Add(SampleConstant());
37+
return declaration;
38+
}
39+
40+
/// <summary>
41+
/// Tests that a clone stays constant, since a copy that quietly became writable would be a copy
42+
/// of something else.
43+
/// </summary>
44+
[TestMethod]
45+
public void Clone_StaysConstant()
46+
{
47+
VariableDeclaration clone = (VariableDeclaration)SampleConstant().Clone();
48+
49+
Assert.IsTrue(clone.IsConstant);
50+
Assert.AreEqual(10, ((LiteralExpression<int>)clone.InitialValue!).Value);
51+
}
52+
53+
/// <summary>
54+
/// Tests that C# writes <c>const</c> in front of the declaration, with the type a C# constant has
55+
/// to name.
56+
/// </summary>
57+
[TestMethod]
58+
public void CSharp_WritesConst()
59+
{
60+
StringAssert.Contains(new CSharpGenerator().Generate(SampleConstant()), "const int MAX = 10;", StringComparison.Ordinal);
61+
StringAssert.Contains(new CSharpGenerator().Generate(SampleClass()), "const int MAX = 10;", StringComparison.Ordinal);
62+
}
63+
64+
/// <summary>
65+
/// Tests that C++ writes <c>const</c> for a local and <c>static constexpr</c> for a class member,
66+
/// which is the spelling that gives the class one compile-time value rather than one per object.
67+
/// </summary>
68+
[TestMethod]
69+
public void Cpp_WritesConstexprForAMember()
70+
{
71+
StringAssert.Contains(new CppGenerator().Generate(SampleConstant()), "const int MAX = 10;", StringComparison.Ordinal);
72+
StringAssert.Contains(new CppGenerator().Generate(SampleClass()), "static constexpr int MAX = 10;", StringComparison.Ordinal);
73+
}
74+
75+
/// <summary>
76+
/// Tests that a constant member with nothing to initialise it stays a plain <c>const</c>, since
77+
/// <c>constexpr</c> without an initialiser does not compile.
78+
/// </summary>
79+
[TestMethod]
80+
public void Cpp_LeavesAnUninitialisedMemberAsConst()
81+
{
82+
ClassDeclaration declaration = new("Limits");
83+
declaration.Members.Add(new VariableDeclaration("MAX", "int") { IsConstant = true });
84+
85+
string code = new CppGenerator().Generate(declaration);
86+
87+
StringAssert.Contains(code, "const int MAX;", StringComparison.Ordinal);
88+
Assert.IsFalse(code.Contains("constexpr", StringComparison.Ordinal));
89+
}
90+
91+
/// <summary>
92+
/// Tests that JavaScript writes <c>const</c> for a local and <c>static</c> for a class member,
93+
/// since <c>const</c> declares a binding in a scope and a class body is not one.
94+
/// </summary>
95+
[TestMethod]
96+
public void JavaScript_WritesStaticForAMember()
97+
{
98+
StringAssert.Contains(new JavaScriptGenerator().Generate(SampleConstant()), "const MAX = 10;", StringComparison.Ordinal);
99+
StringAssert.Contains(new JavaScriptGenerator().Generate(SampleClass()), "static MAX = 10;", StringComparison.Ordinal);
100+
}
101+
102+
/// <summary>
103+
/// Tests that a private constant member is spelled with both, since the two are independent.
104+
/// </summary>
105+
[TestMethod]
106+
public void JavaScript_CombinesStaticWithAPrivateName()
107+
{
108+
ClassDeclaration declaration = new("Limits");
109+
declaration.Members.Add(new VariableDeclaration("MAX", "int", Literal.Number(10))
110+
{
111+
IsConstant = true,
112+
Visibility = Visibility.Private,
113+
});
114+
115+
StringAssert.Contains(new JavaScriptGenerator().Generate(declaration), "static #MAX = 10;", StringComparison.Ordinal);
116+
}
117+
118+
/// <summary>
119+
/// Tests that Python emits an ordinary assignment, since it has no constant declaration and the
120+
/// upper-case naming that stands in for one is a convention about the identifier rather than
121+
/// something the declaration can say.
122+
/// </summary>
123+
[TestMethod]
124+
public void Python_EmitsAnOrdinaryAssignment()
125+
{
126+
string code = new PythonGenerator().Generate(SampleConstant());
127+
128+
StringAssert.Contains(code, "MAX = 10", StringComparison.Ordinal);
129+
Assert.IsFalse(code.Contains("const", StringComparison.Ordinal));
130+
}
131+
132+
/// <summary>
133+
/// Tests that a constant survives a round trip through YAML, value and all.
134+
/// </summary>
135+
[TestMethod]
136+
public void Yaml_RoundTripsAConstant()
137+
{
138+
string yaml = new YamlSerializer().Serialize(SampleConstant());
139+
VariableDeclaration restored = (VariableDeclaration)new YamlDeserializer().Deserialize(yaml)!;
140+
141+
Assert.IsTrue(restored.IsConstant);
142+
Assert.AreEqual("MAX", restored.Name);
143+
Assert.AreEqual(10, ((LiteralExpression<int>)restored.InitialValue!).Value);
144+
}
145+
146+
/// <summary>
147+
/// Tests that the palette offers a constant already holding a literal, so it is a node the user
148+
/// edits rather than one they have to wire a value into first.
149+
/// </summary>
150+
[TestMethod]
151+
public void Catalog_OffersAConstantHoldingALiteral()
152+
{
153+
AstNode created = AstNodeCatalog.Templates
154+
.Single(template => string.Equals(template.Label, "Constant", StringComparison.Ordinal))
155+
.Create();
156+
157+
VariableDeclaration constant = (VariableDeclaration)created;
158+
159+
Assert.IsTrue(constant.IsConstant);
160+
Assert.IsInstanceOfType<LiteralExpression<int>>(constant.InitialValue);
161+
}
162+
163+
/// <summary>
164+
/// Tests that a constant with nothing in it is reported rather than generated, since a constant
165+
/// is the one declaration whose value is not optional.
166+
/// </summary>
167+
[TestMethod]
168+
public void Graph_ReportsAConstantWithNoValue()
169+
{
170+
VariableDeclaration constant = new("MAX", "int") { IsConstant = true };
171+
AstGraph graph = new(constant);
172+
173+
Assert.IsTrue(graph.Validate().Any(problem => ReferenceEquals(problem.Node, constant)));
174+
}
175+
}

0 commit comments

Comments
 (0)