diff --git a/CLAUDE.md b/CLAUDE.md
index 8665918..1851d71 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs
index 4887caf..b01ce67 100644
--- a/Coder.Graph/AstFields.cs
+++ b/Coder.Graph/AstFields.cs
@@ -82,14 +82,18 @@ public AstField(string name, AstFieldKind kind, string value)
public static class AstFields
{
///
- /// The access modifiers a class declaration offers.
+ /// The visibilities a declaration offers.
///
- private static readonly IReadOnlyList AccessModifiers =
+ ///
+ /// Built from the enumeration so a visibility the AST gains appears in the inspector without
+ /// anyone remembering to list it here. is labelled for what
+ /// it means rather than by its name: it is not a fifth modifier, it is the absence of one.
+ ///
+ private static readonly IReadOnlyList Visibilities =
[
- new("public", "public"),
- new("internal", "internal"),
- new("protected", "protected"),
- new("private", "private"),
+ .. Enum.GetValues().Select(visibility => new AstFieldChoice(
+ visibility.ToString(),
+ visibility == Visibility.Unspecified ? "(language default)" : visibility.ToString().ToLowerInvariant())),
];
///
@@ -107,13 +111,20 @@ public static IReadOnlyList Of(AstNode node)
[
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),
],
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 =>
@@ -130,7 +141,7 @@ public static IReadOnlyList Of(AstNode node)
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 =>
@@ -207,10 +218,18 @@ public static bool TryWrite(AstNode node, string fieldName, string value)
{
(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)),
@@ -221,7 +240,8 @@ public static bool TryWrite(AstNode node, string fieldName, string value)
(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),
@@ -316,6 +336,11 @@ private static bool TryParseDouble(string value, out double result) =>
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);
diff --git a/Coder.Graph/AstNodeCatalog.cs b/Coder.Graph/AstNodeCatalog.cs
index 76fcf94..e7f5747 100644
--- a/Coder.Graph/AstNodeCatalog.cs
+++ b/Coder.Graph/AstNodeCatalog.cs
@@ -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().Select(op => new AstNodeTemplate(
diff --git a/Coder.Graph/AstSchema.cs b/Coder.Graph/AstSchema.cs
index 1db3b8e..8a411d8 100644
--- a/Coder.Graph/AstSchema.cs
+++ b/Coder.Graph/AstSchema.cs
@@ -45,6 +45,7 @@ public static class AstSchema
{
ClassDeclaration => [MembersSlot],
FunctionDeclaration => [ParametersSlot, BodySlot],
+ EntryPoint => [BodySlot],
ReturnStatement => [ExpressionSlot],
BinaryExpression => [LeftSlot, RightSlot],
UnaryExpression => [OperandSlot],
@@ -87,6 +88,7 @@ public static IReadOnlyList 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)),
};
@@ -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;
@@ -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;
@@ -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;
@@ -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,
};
}
@@ -367,6 +383,7 @@ public static string Describe(AstNode node)
{
ClassDeclaration classDecl => $"class {classDecl.Name ?? ""}",
FunctionDeclaration function => $"function {function.Name ?? ""}",
+ EntryPoint => "entry point",
Parameter parameter => $"param {parameter.Name ?? ""}",
ReturnStatement => "return",
BinaryExpression binary => $"binary {SpellOrName(binary.Operator)}",
diff --git a/Coder.Test/Ast/ClassDeclarationTests.cs b/Coder.Test/Ast/ClassDeclarationTests.cs
index 62043aa..7c7a102 100644
--- a/Coder.Test/Ast/ClassDeclarationTests.cs
+++ b/Coder.Test/Ast/ClassDeclarationTests.cs
@@ -137,13 +137,13 @@ public void CSharp_GeneratesAClass()
}
///
- /// 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.
///
[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);
@@ -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(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);
}
}
diff --git a/Coder.Test/Ast/ConstantDeclarationTests.cs b/Coder.Test/Ast/ConstantDeclarationTests.cs
new file mode 100644
index 0000000..b024ee7
--- /dev/null
+++ b/Coder.Test/Ast/ConstantDeclarationTests.cs
@@ -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;
+
+///
+/// Tests for a constant: a marked
+/// and initialised from a literal.
+///
+///
+/// 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.
+///
+[TestClass]
+public class ConstantDeclarationTests
+{
+ ///
+ /// Builds a constant with a literal value.
+ ///
+ /// The declaration.
+ private static VariableDeclaration SampleConstant() =>
+ new("MAX", "int", Literal.Number(10)) { IsConstant = true };
+
+ ///
+ /// Builds a class holding one constant member.
+ ///
+ /// The class.
+ private static ClassDeclaration SampleClass()
+ {
+ ClassDeclaration declaration = new("Limits");
+ declaration.Members.Add(SampleConstant());
+ return declaration;
+ }
+
+ ///
+ /// Tests that a clone stays constant, since a copy that quietly became writable would be a copy
+ /// of something else.
+ ///
+ [TestMethod]
+ public void Clone_StaysConstant()
+ {
+ VariableDeclaration clone = (VariableDeclaration)SampleConstant().Clone();
+
+ Assert.IsTrue(clone.IsConstant);
+ Assert.AreEqual(10, ((LiteralExpression)clone.InitialValue!).Value);
+ }
+
+ ///
+ /// Tests that C# writes const in front of the declaration, with the type a C# constant has
+ /// to name.
+ ///
+ [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);
+ }
+
+ ///
+ /// Tests that C++ writes const for a local and static constexpr for a class member,
+ /// which is the spelling that gives the class one compile-time value rather than one per object.
+ ///
+ [TestMethod]
+ public void Cpp_WritesConstexprForAMember()
+ {
+ StringAssert.Contains(new CppGenerator().Generate(SampleConstant()), "const int MAX = 10;", StringComparison.Ordinal);
+ StringAssert.Contains(new CppGenerator().Generate(SampleClass()), "static constexpr int MAX = 10;", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that a constant member with nothing to initialise it stays a plain const, since
+ /// constexpr without an initialiser does not compile.
+ ///
+ [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);
+ Assert.IsFalse(code.Contains("constexpr", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Tests that JavaScript writes const for a local and static for a class member,
+ /// since const declares a binding in a scope and a class body is not one.
+ ///
+ [TestMethod]
+ public void JavaScript_WritesStaticForAMember()
+ {
+ StringAssert.Contains(new JavaScriptGenerator().Generate(SampleConstant()), "const MAX = 10;", StringComparison.Ordinal);
+ StringAssert.Contains(new JavaScriptGenerator().Generate(SampleClass()), "static MAX = 10;", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that a private constant member is spelled with both, since the two are independent.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [TestMethod]
+ public void Python_EmitsAnOrdinaryAssignment()
+ {
+ string code = new PythonGenerator().Generate(SampleConstant());
+
+ StringAssert.Contains(code, "MAX = 10", StringComparison.Ordinal);
+ Assert.IsFalse(code.Contains("const", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Tests that a constant survives a round trip through YAML, value and all.
+ ///
+ [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)restored.InitialValue!).Value);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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>(constant.InitialValue);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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)));
+ }
+}
diff --git a/Coder.Test/Ast/EntryPointTests.cs b/Coder.Test/Ast/EntryPointTests.cs
new file mode 100644
index 0000000..9ff5e0d
--- /dev/null
+++ b/Coder.Test/Ast/EntryPointTests.cs
@@ -0,0 +1,241 @@
+// 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;
+
+///
+/// Tests for : what it holds, how each language spells the place a program
+/// starts running, and that it survives a round trip through YAML.
+///
+///
+/// The whole reason the entry point is a node of its own is that no two languages spell it the same
+/// way, so each generator is checked for its own spelling rather than for a shared one.
+///
+[TestClass]
+public class EntryPointTests
+{
+ ///
+ /// Builds an entry point that takes arguments, returns an exit code and has a statement in it.
+ ///
+ /// The entry point.
+ private static EntryPoint SampleEntryPoint()
+ {
+ EntryPoint entryPoint = new() { AcceptsArguments = true, ReturnsExitCode = true };
+ entryPoint.Body.Add(new VariableDeclaration("count", "int", Literal.Number(0)));
+ entryPoint.Body.Add(new ReturnStatement(Literal.Number(0)));
+ return entryPoint;
+ }
+
+ ///
+ /// Tests that a clone copies the body rather than sharing it, so editing one program does not
+ /// edit the other.
+ ///
+ [TestMethod]
+ public void Clone_CopiesTheBodyRatherThanSharingIt()
+ {
+ EntryPoint original = SampleEntryPoint();
+
+ EntryPoint clone = (EntryPoint)original.Clone();
+
+ Assert.IsTrue(clone.AcceptsArguments);
+ Assert.IsTrue(clone.ReturnsExitCode);
+ Assert.AreEqual(original.Body.Count, clone.Body.Count);
+ Assert.AreNotSame(original.Body[0], clone.Body[0]);
+
+ ((VariableDeclaration)clone.Body[0]).Name = "total";
+ Assert.AreEqual("count", ((VariableDeclaration)original.Body[0]).Name);
+ }
+
+ ///
+ /// Tests that the node reports the type name the serializer writes and reads it back under.
+ ///
+ [TestMethod]
+ public void NodeTypeName_IsEntryPoint() => Assert.AreEqual("EntryPoint", new EntryPoint().GetNodeTypeName());
+
+ ///
+ /// Tests that the editor sees an entry point as one slot holding its statements.
+ ///
+ [TestMethod]
+ public void Schema_ExposesTheBodyAsOneSlot()
+ {
+ EntryPoint entryPoint = SampleEntryPoint();
+ AstSlot body = AstSchema.SlotsOf(entryPoint).Single();
+
+ Assert.AreEqual("Body", body.Name);
+ Assert.AreEqual(AstSlotCardinality.Many, body.Cardinality);
+ Assert.AreEqual(2, AstSchema.ChildrenOf(entryPoint, body).Count);
+
+ Assert.IsTrue(AstSchema.TryAttach(entryPoint, body, new ReturnStatement()));
+ Assert.AreEqual(3, AstSchema.ChildrenOf(entryPoint, body).Count);
+
+ Assert.IsTrue(AstSchema.TryDetachAt(entryPoint, body, 0));
+ Assert.AreEqual(2, AstSchema.ChildrenOf(entryPoint, body).Count);
+ }
+
+ ///
+ /// Tests that an entry point can be a class member — which is where C# puts Main — but not
+ /// a statement inside a body, since a program does not start running part-way through a function.
+ ///
+ [TestMethod]
+ public void Schema_TakesAnEntryPointAsAMemberButNotAsAStatement()
+ {
+ AstSlot members = AstSchema.SlotsOf(new ClassDeclaration("Program")).Single();
+ AstSlot body = AstSchema.SlotsOf(new FunctionDeclaration("run")).Single(slot => slot.Name == "Body");
+
+ Assert.IsTrue(AstSchema.Accepts(members, new EntryPoint()));
+ Assert.IsFalse(AstSchema.Accepts(body, new EntryPoint()));
+ }
+
+ ///
+ /// Tests that the editor has a caption for an entry point, since a node with no caption is a blank
+ /// title bar on screen.
+ ///
+ [TestMethod]
+ public void Schema_DescribesAnEntryPoint() => Assert.AreEqual("entry point", AstSchema.Describe(new EntryPoint()));
+
+ ///
+ /// Tests that C# emits the static Main it looks for, with the argument array and the exit
+ /// code the node asked for.
+ ///
+ [TestMethod]
+ public void CSharp_GeneratesMain()
+ {
+ string code = new CSharpGenerator().Generate(SampleEntryPoint());
+
+ StringAssert.Contains(code, "public static int Main(string[] args)", StringComparison.Ordinal);
+ StringAssert.Contains(code, "int count = 0;", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that an entry point that takes nothing and returns nothing is emitted as a bare void
+ /// Main, rather than one with an unused parameter.
+ ///
+ [TestMethod]
+ public void CSharp_GeneratesABareMainWhenNothingIsAskedFor()
+ {
+ string code = new CSharpGenerator().Generate(new EntryPoint());
+
+ StringAssert.Contains(code, "public static void Main()", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that C++ emits the free int main the standard names, with argc and
+ /// argv when the program reads its arguments.
+ ///
+ [TestMethod]
+ public void Cpp_GeneratesMain()
+ {
+ StringAssert.Contains(new CppGenerator().Generate(SampleEntryPoint()), "int main(int argc, char* argv[])", StringComparison.Ordinal);
+
+ // C++'s main returns int whether or not the program hands back an exit code.
+ StringAssert.Contains(new CppGenerator().Generate(new EntryPoint()), "int main()", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that Python emits the function along with the __main__ guard that runs it, and the
+ /// import the arguments and exit code need.
+ ///
+ [TestMethod]
+ public void Python_GeneratesMainAndItsGuard()
+ {
+ string code = new PythonGenerator().Generate(SampleEntryPoint());
+
+ StringAssert.Contains(code, "import sys", StringComparison.Ordinal);
+ StringAssert.Contains(code, "def main(args):", StringComparison.Ordinal);
+ StringAssert.Contains(code, "if __name__ == \"__main__\":", StringComparison.Ordinal);
+ StringAssert.Contains(code, "sys.exit(main(sys.argv[1:]))", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that an entry point needing neither arguments nor an exit code imports nothing, since an
+ /// unused import is something a linter will complain about.
+ ///
+ [TestMethod]
+ public void Python_ImportsNothingWhenSysIsNotNeeded()
+ {
+ string code = new PythonGenerator().Generate(new EntryPoint());
+
+ Assert.IsFalse(code.Contains("import sys", StringComparison.Ordinal));
+ StringAssert.Contains(code, "def main():", StringComparison.Ordinal);
+ StringAssert.Contains(code, "pass", StringComparison.Ordinal);
+ StringAssert.Contains(code, "main()", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that JavaScript emits the function along with the call that runs it, since a module that
+ /// only defines main does nothing when it is run.
+ ///
+ [TestMethod]
+ public void JavaScript_GeneratesMainAndCallsIt()
+ {
+ string code = new JavaScriptGenerator().Generate(SampleEntryPoint());
+
+ StringAssert.Contains(code, "function main(args) {", StringComparison.Ordinal);
+ StringAssert.Contains(code, "process.exit(main(process.argv.slice(2)));", StringComparison.Ordinal);
+
+ StringAssert.Contains(new JavaScriptGenerator().Generate(new EntryPoint()), "main();", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that every generator accepts an entry point, so a document rooted at one can be generated
+ /// rather than refused.
+ ///
+ [TestMethod]
+ public void EveryGenerator_AcceptsAnEntryPoint()
+ {
+ foreach (ILanguageGenerator generator in new ILanguageGenerator[]
+ { new CSharpGenerator(), new CppGenerator(), new PythonGenerator(), new JavaScriptGenerator() })
+ {
+ Assert.IsTrue(generator.CanGenerate(new EntryPoint()), generator.DisplayName);
+ }
+ }
+
+ ///
+ /// Tests that an entry point survives a round trip through YAML, which is how the editor saves.
+ ///
+ [TestMethod]
+ public void Yaml_RoundTripsAnEntryPoint()
+ {
+ EntryPoint original = SampleEntryPoint();
+
+ string yaml = new YamlSerializer().Serialize(original);
+ EntryPoint restored = (EntryPoint)new YamlDeserializer().Deserialize(yaml)!;
+
+ Assert.IsTrue(restored.AcceptsArguments);
+ Assert.IsTrue(restored.ReturnsExitCode);
+ Assert.AreEqual(2, restored.Body.Count);
+ Assert.AreEqual(new CSharpGenerator().Generate(original), new CSharpGenerator().Generate(restored));
+ }
+
+ ///
+ /// Tests that the inspector edits the two things that vary about an entry point.
+ ///
+ [TestMethod]
+ public void Fields_EditArgumentsAndExitCode()
+ {
+ EntryPoint entryPoint = new();
+
+ Assert.IsTrue(AstFields.TryWrite(entryPoint, "Arguments", "true"));
+ Assert.IsTrue(AstFields.TryWrite(entryPoint, "ExitCode", "true"));
+
+ Assert.IsTrue(entryPoint.AcceptsArguments);
+ Assert.IsTrue(entryPoint.ReturnsExitCode);
+
+ // A value that is not a flag leaves the document alone.
+ Assert.IsFalse(AstFields.TryWrite(entryPoint, "Arguments", "maybe"));
+ Assert.IsTrue(entryPoint.AcceptsArguments);
+ }
+
+ ///
+ /// Tests that the palette offers an entry point, since a program with no way to start is not one
+ /// a user can build from the menu.
+ ///
+ [TestMethod]
+ public void Catalog_OffersAnEntryPoint() =>
+ Assert.IsTrue(AstNodeCatalog.Templates.Any(template => template.Create() is EntryPoint));
+}
diff --git a/Coder.Test/Ast/VisibilityTests.cs b/Coder.Test/Ast/VisibilityTests.cs
new file mode 100644
index 0000000..f06a037
--- /dev/null
+++ b/Coder.Test/Ast/VisibilityTests.cs
@@ -0,0 +1,286 @@
+// 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;
+
+///
+/// Tests for : what each declaration carries, how each language spells it,
+/// and that it survives a round trip through YAML.
+///
+///
+/// Visibility is the first thing the AST models that no two languages spell the same way — a C#
+/// keyword, a C++ access label, a JavaScript name prefix, and nothing at all in Python — so these
+/// cover each spelling rather than asserting one shape four times.
+///
+[TestClass]
+public class VisibilityTests
+{
+ ///
+ /// Builds a class whose three members ask for three different visibilities.
+ ///
+ /// The class.
+ private static ClassDeclaration SampleClass()
+ {
+ ClassDeclaration declaration = new("Point");
+ declaration.Members.Add(new VariableDeclaration("origin", "int", Literal.Number(0)) { Visibility = Visibility.Public });
+ declaration.Members.Add(new VariableDeclaration("x", "int", Literal.Number(0)) { Visibility = Visibility.Private });
+
+ FunctionDeclaration area = new("area") { ReturnType = "int", Visibility = Visibility.Protected };
+ area.Body.Add(new ReturnStatement(new VariableReference("x")));
+ declaration.Members.Add(area);
+
+ return declaration;
+ }
+
+ ///
+ /// Tests that a declaration nobody has given a visibility to carries none, rather than silently
+ /// acquiring one the author never asked for.
+ ///
+ [TestMethod]
+ public void Unspecified_IsWhatADeclarationStartsWith()
+ {
+ Assert.AreEqual(Visibility.Unspecified, new ClassDeclaration("Point").Visibility);
+ Assert.AreEqual(Visibility.Unspecified, new FunctionDeclaration("area").Visibility);
+ Assert.AreEqual(Visibility.Unspecified, new VariableDeclaration("x", "int").Visibility);
+ }
+
+ ///
+ /// Tests that a clone carries the visibility over, since a copied declaration that quietly became
+ /// public would be a copy of something else.
+ ///
+ [TestMethod]
+ public void Clone_CarriesTheVisibility()
+ {
+ ClassDeclaration classClone = (ClassDeclaration)new ClassDeclaration("Point") { Visibility = Visibility.Internal }.Clone();
+ FunctionDeclaration functionClone = (FunctionDeclaration)new FunctionDeclaration("area") { Visibility = Visibility.Protected }.Clone();
+ VariableDeclaration variableClone = (VariableDeclaration)new VariableDeclaration("x", "int") { Visibility = Visibility.Private }.Clone();
+
+ Assert.AreEqual(Visibility.Internal, classClone.Visibility);
+ Assert.AreEqual(Visibility.Protected, functionClone.Visibility);
+ Assert.AreEqual(Visibility.Private, variableClone.Visibility);
+ }
+
+ ///
+ /// Tests that C# writes the keyword for each visibility, and that a member without one is public
+ /// rather than carrying no modifier at all.
+ ///
+ [TestMethod]
+ public void CSharp_WritesTheKeyword()
+ {
+ string code = new CSharpGenerator().Generate(SampleClass());
+
+ StringAssert.Contains(code, "public int origin = 0;", StringComparison.Ordinal);
+ StringAssert.Contains(code, "private int x = 0;", StringComparison.Ordinal);
+ StringAssert.Contains(code, "protected int area()", StringComparison.Ordinal);
+ StringAssert.Contains(new CSharpGenerator().Generate(new FunctionDeclaration("area") { ReturnType = "int" }), "public int area()", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that a local variable — one with no visibility — is emitted without a modifier, since a
+ /// modifier on a local does not compile.
+ ///
+ [TestMethod]
+ public void CSharp_LeavesALocalUnmodified()
+ {
+ FunctionDeclaration function = new("run") { ReturnType = "void" };
+ function.Body.Add(new VariableDeclaration("total", "int", Literal.Number(0)));
+
+ StringAssert.Contains(new CSharpGenerator().Generate(function), "int total = 0;", StringComparison.Ordinal);
+ Assert.IsFalse(new CSharpGenerator().Generate(function).Contains("public int total", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Tests that C++ groups the members under the access label each one asks for, which is how C++
+ /// spells visibility.
+ ///
+ [TestMethod]
+ public void Cpp_GroupsMembersUnderAccessLabels()
+ {
+ string code = new CppGenerator().Generate(SampleClass());
+
+ StringAssert.Contains(code, "public:", StringComparison.Ordinal);
+ StringAssert.Contains(code, "private:", StringComparison.Ordinal);
+ StringAssert.Contains(code, "protected:", StringComparison.Ordinal);
+
+ // The label has to come before the member it governs, not after it.
+ Assert.IsTrue(code.IndexOf("private:", StringComparison.Ordinal) < code.IndexOf("int x = 0;", StringComparison.Ordinal));
+ Assert.IsTrue(code.IndexOf("protected:", StringComparison.Ordinal) < code.IndexOf("int area()", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Tests that a member with no visibility of its own lands under public:, since an
+ /// unlabelled C++ class body is private and nothing outside the class could reach it.
+ ///
+ [TestMethod]
+ public void Cpp_PutsAnUnspecifiedMemberUnderPublic()
+ {
+ ClassDeclaration declaration = new("Point");
+ declaration.Members.Add(new VariableDeclaration("x", "int", Literal.Number(0)));
+
+ string code = new CppGenerator().Generate(declaration);
+
+ Assert.IsTrue(code.IndexOf("public:", StringComparison.Ordinal) < code.IndexOf("int x = 0;", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Tests that C++ puts an internal member under public:, since C++ has no assembly for a
+ /// declaration to be internal to.
+ ///
+ [TestMethod]
+ public void Cpp_TreatsInternalAsPublic()
+ {
+ ClassDeclaration declaration = new("Point");
+ declaration.Members.Add(new VariableDeclaration("x", "int", Literal.Number(0)) { Visibility = Visibility.Internal });
+
+ string code = new CppGenerator().Generate(declaration);
+
+ StringAssert.Contains(code, "public:", StringComparison.Ordinal);
+ Assert.IsFalse(code.Contains("internal", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Tests that JavaScript spells a private member with the # prefix its own private syntax
+ /// uses, and leaves the others as ordinary members.
+ ///
+ [TestMethod]
+ public void JavaScript_SpellsAPrivateMemberWithAHash()
+ {
+ string code = new JavaScriptGenerator().Generate(SampleClass());
+
+ StringAssert.Contains(code, "#x = 0;", StringComparison.Ordinal);
+ StringAssert.Contains(code, "origin = 0;", StringComparison.Ordinal);
+
+ // protected has no JavaScript spelling, so the method is an ordinary one.
+ StringAssert.Contains(code, "area() {", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that a private method is spelled with the prefix too, since JavaScript's # applies
+ /// to any class member rather than to fields alone.
+ ///
+ [TestMethod]
+ public void JavaScript_SpellsAPrivateMethodWithAHash()
+ {
+ ClassDeclaration declaration = new("Point");
+ declaration.Members.Add(new FunctionDeclaration("recompute") { ReturnType = "void", Visibility = Visibility.Private });
+
+ StringAssert.Contains(new JavaScriptGenerator().Generate(declaration), "#recompute() {", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Tests that Python drops visibility rather than renaming the declaration, since its convention
+ /// for a non-public member is a spelling of the identifier and renaming one here would leave every
+ /// reference to it naming something that no longer exists.
+ ///
+ [TestMethod]
+ public void Python_DropsVisibilityRatherThanRenaming()
+ {
+ string code = new PythonGenerator().Generate(SampleClass());
+
+ StringAssert.Contains(code, "x = 0", StringComparison.Ordinal);
+ StringAssert.Contains(code, "def area(self) -> int:", StringComparison.Ordinal);
+ Assert.IsFalse(code.Contains("_x", StringComparison.Ordinal));
+ Assert.IsFalse(code.Contains("private", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Tests that visibility survives a round trip through YAML, on each of the declarations that can
+ /// carry one.
+ ///
+ [TestMethod]
+ public void Yaml_RoundTripsVisibility()
+ {
+ ClassDeclaration original = SampleClass();
+ original.Visibility = Visibility.Internal;
+
+ string yaml = new YamlSerializer().Serialize(original);
+ ClassDeclaration restored = (ClassDeclaration)new YamlDeserializer().Deserialize(yaml)!;
+
+ Assert.AreEqual(Visibility.Internal, restored.Visibility);
+ Assert.AreEqual(Visibility.Public, ((VariableDeclaration)restored.Members[0]).Visibility);
+ Assert.AreEqual(Visibility.Private, ((VariableDeclaration)restored.Members[1]).Visibility);
+ Assert.AreEqual(Visibility.Protected, ((FunctionDeclaration)restored.Members[2]).Visibility);
+ }
+
+ ///
+ /// Tests that a declaration with no visibility writes no key at all, so the document says nothing
+ /// rather than saying "unspecified".
+ ///
+ [TestMethod]
+ public void Yaml_WritesNothingForAnUnspecifiedVisibility()
+ {
+ string yaml = new YamlSerializer().Serialize(new ClassDeclaration("Point"));
+
+ Assert.IsFalse(yaml.Contains("visibility", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Tests that a document written before visibility became an enumeration still opens, since a
+ /// saved document should not stop loading because the library changed how it models the same idea.
+ ///
+ [TestMethod]
+ public void Yaml_ReadsTheOlderAccessModifierSpelling()
+ {
+ const string yaml = """
+ classDeclaration:
+ name: Point
+ accessModifier: internal
+ """;
+
+ ClassDeclaration restored = (ClassDeclaration)new YamlDeserializer().Deserialize(yaml)!;
+
+ Assert.AreEqual(Visibility.Internal, restored.Visibility);
+ }
+
+ ///
+ /// Tests that the inspector offers visibility on every declaration that can carry one, and writes
+ /// the choice the user picked.
+ ///
+ [TestMethod]
+ public void Fields_OfferVisibilityOnEveryDeclaration()
+ {
+ ClassDeclaration classDecl = new("Point");
+ FunctionDeclaration function = new("area") { ReturnType = "int" };
+ VariableDeclaration variable = new("x", "int");
+
+ Assert.IsTrue(AstFields.TryWrite(classDecl, "Visibility", "Internal"));
+ Assert.IsTrue(AstFields.TryWrite(function, "Visibility", "Protected"));
+ Assert.IsTrue(AstFields.TryWrite(variable, "Visibility", "Private"));
+
+ Assert.AreEqual(Visibility.Internal, classDecl.Visibility);
+ Assert.AreEqual(Visibility.Protected, function.Visibility);
+ Assert.AreEqual(Visibility.Private, variable.Visibility);
+ }
+
+ ///
+ /// Tests that the inspector offers each visibility as a choice, so it is picked from a menu rather
+ /// than typed and misspelled.
+ ///
+ [TestMethod]
+ public void Fields_ListEveryVisibilityAsAChoice()
+ {
+ AstField field = AstFields.Of(new ClassDeclaration("Point")).Single(f => f.Name == "Visibility");
+
+ Assert.AreEqual(AstFieldKind.Choice, field.Kind);
+ Assert.AreEqual(Enum.GetValues().Length, field.Choices.Count);
+ Assert.IsTrue(field.Choices.Any(choice => choice.Value == nameof(Visibility.Internal)));
+ }
+
+ ///
+ /// Tests that a value that names no visibility is refused, leaving the document alone rather than
+ /// writing a default over what the user had.
+ ///
+ [TestMethod]
+ public void Fields_RefuseAValueThatNamesNoVisibility()
+ {
+ ClassDeclaration classDecl = new("Point") { Visibility = Visibility.Internal };
+
+ Assert.IsFalse(AstFields.TryWrite(classDecl, "Visibility", "friendly"));
+ Assert.AreEqual(Visibility.Internal, classDecl.Visibility);
+ }
+}
diff --git a/Coder.Test/Graph/AstFieldsTests.cs b/Coder.Test/Graph/AstFieldsTests.cs
index afb79d7..f425718 100644
--- a/Coder.Test/Graph/AstFieldsTests.cs
+++ b/Coder.Test/Graph/AstFieldsTests.cs
@@ -167,7 +167,7 @@ public void TryWrite_EditsDeclarations()
Assert.IsTrue(AstFields.TryWrite(parameter, "Optional", "true"));
Assert.IsTrue(AstFields.TryWrite(variable, "Constant", "true"));
Assert.IsTrue(AstFields.TryWrite(classDecl, "BaseType", "Shape"));
- Assert.IsTrue(AstFields.TryWrite(classDecl, "Access", "internal"));
+ Assert.IsTrue(AstFields.TryWrite(classDecl, "Visibility", "Internal"));
Assert.AreEqual("total", function.Name);
Assert.AreEqual("int", function.ReturnType);
@@ -176,7 +176,7 @@ public void TryWrite_EditsDeclarations()
Assert.IsTrue(parameter.IsOptional);
Assert.IsTrue(variable.IsConstant);
Assert.AreEqual("Shape", classDecl.BaseType);
- Assert.AreEqual("internal", classDecl.AccessModifier);
+ Assert.AreEqual(Visibility.Internal, classDecl.Visibility);
}
///
diff --git a/Coder/Ast/ClassDeclaration.cs b/Coder/Ast/ClassDeclaration.cs
index 52a1d01..def148a 100644
--- a/Coder/Ast/ClassDeclaration.cs
+++ b/Coder/Ast/ClassDeclaration.cs
@@ -16,7 +16,7 @@ namespace ktsu.Coder.Ast;
/// its declaration syntax, and interfaces are a language feature the AST does not model yet.
///
///
-public class ClassDeclaration : AstCompositeNode
+public class ClassDeclaration : AstCompositeNode, IHasVisibility
{
///
/// Initializes a new instance of the class.
@@ -42,9 +42,9 @@ public ClassDeclaration()
public string? BaseType { get; set; }
///
- /// Gets or sets the visibility/access modifier (public, internal, …), or null for the language's default.
+ /// Gets or sets how widely the class is visible.
///
- public string? AccessModifier { get; set; }
+ public Visibility Visibility { get; set; }
///
/// Gets the members the class declares, in the order they should be emitted.
@@ -67,7 +67,7 @@ public override AstNode Clone()
{
Name = Name,
BaseType = BaseType,
- AccessModifier = AccessModifier
+ Visibility = Visibility
};
foreach ((string key, object? value) in Metadata)
diff --git a/Coder/Ast/EntryPoint.cs b/Coder/Ast/EntryPoint.cs
new file mode 100644
index 0000000..896b129
--- /dev/null
+++ b/Coder/Ast/EntryPoint.cs
@@ -0,0 +1,84 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+using System.Collections.ObjectModel;
+
+///
+/// Represents the point a program starts running at: main, or whatever the target language
+/// calls it.
+///
+///
+/// A node of its own rather than a function named "main", because every language spells its entry
+/// point differently and only some of them spell it as a function at all. C# wants a static
+/// Main returning void or int, C++ wants a free int main taking
+/// argc and argv, Python wants a function plus the __main__ guard that calls
+/// it, and JavaScript wants a function plus the call. Naming the intent lets each generator write
+/// its own spelling; a called "main" would only be right for
+/// whichever language it was written for.
+///
+/// and are the only two things that
+/// vary about an entry point across those languages, so they are what the node carries. The name,
+/// the parameter spelling and the wiring that runs it are the generator's business.
+///
+///
+public class EntryPoint : AstCompositeNode
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public EntryPoint()
+ {
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the program reads the command line arguments.
+ ///
+ public bool AcceptsArguments { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the program returns an exit code to its caller.
+ ///
+ public bool ReturnsExitCode { get; set; }
+
+ ///
+ /// Gets the statements the program runs, in order.
+ ///
+ public Collection Body { get; init; } = [];
+
+ ///
+ /// Gets the type name of this node for serialization purposes.
+ ///
+ /// The name of the node type.
+ public override string GetNodeTypeName() => "EntryPoint";
+
+ ///
+ /// Creates a deep clone of this entry point.
+ ///
+ /// A new instance with the same properties and a cloned body.
+ public override AstNode Clone()
+ {
+ EntryPoint clone = new()
+ {
+ AcceptsArguments = AcceptsArguments,
+ ReturnsExitCode = ReturnsExitCode
+ };
+
+ foreach ((string key, object? value) in Metadata)
+ {
+ clone.Metadata[key] = value;
+ }
+
+ foreach (AstNode statement in Body)
+ {
+ clone.Body.Add(statement.Clone());
+ }
+
+ foreach ((string key, AstNode child) in Children)
+ {
+ clone.Children[key] = child.Clone();
+ }
+
+ return clone;
+ }
+}
diff --git a/Coder/Ast/FunctionDeclaration.cs b/Coder/Ast/FunctionDeclaration.cs
index b1c9fa3..b66b916 100644
--- a/Coder/Ast/FunctionDeclaration.cs
+++ b/Coder/Ast/FunctionDeclaration.cs
@@ -7,7 +7,7 @@ namespace ktsu.Coder.Ast;
///
/// Represents a function declaration in the abstract syntax tree.
///
-public class FunctionDeclaration : AstCompositeNode
+public class FunctionDeclaration : AstCompositeNode, IHasVisibility
{
///
/// Initializes a new instance of the class.
@@ -32,6 +32,11 @@ public FunctionDeclaration()
///
public string? ReturnType { get; set; }
+ ///
+ /// Gets or sets how widely the function is visible.
+ ///
+ public Visibility Visibility { get; set; }
+
///
/// Gets or sets a list of parameters for the function.
///
@@ -57,7 +62,8 @@ public override AstNode Clone()
FunctionDeclaration clone = new()
{
Name = Name,
- ReturnType = ReturnType
+ ReturnType = ReturnType,
+ Visibility = Visibility
};
// Copy metadata
diff --git a/Coder/Ast/VariableDeclaration.cs b/Coder/Ast/VariableDeclaration.cs
index 90e9b92..06ee23f 100644
--- a/Coder/Ast/VariableDeclaration.cs
+++ b/Coder/Ast/VariableDeclaration.cs
@@ -6,7 +6,7 @@ namespace ktsu.Coder.Ast;
/// Represents a variable declaration statement.
/// Examples: int x; string name = "hello"; var result = 42;
///
-public class VariableDeclaration : AstNode
+public class VariableDeclaration : AstNode, IHasVisibility
{
///
/// Gets or sets the name of the variable.
@@ -36,9 +36,14 @@ public class VariableDeclaration : AstNode
public bool IsTypeInferred { get; set; }
///
- /// Gets or sets the visibility/access modifier (public, private, etc.).
+ /// Gets or sets how widely the declaration is visible.
///
- public string? AccessModifier { get; set; }
+ ///
+ /// A local variable has no visibility of its own, so one left
+ /// is emitted without a modifier — which is what a local needs and what a field in a language
+ /// with a sensible default wants too.
+ ///
+ public Visibility Visibility { get; set; }
///
/// Initializes a new instance of the class.
@@ -81,7 +86,7 @@ public override AstNode Clone()
InitialValue = (Expression?)InitialValue?.DeepClone(),
IsConstant = IsConstant,
IsTypeInferred = IsTypeInferred,
- AccessModifier = AccessModifier
+ Visibility = Visibility
};
// Copy metadata
@@ -101,7 +106,7 @@ public override string ToString()
{
string typeInfo = IsTypeInferred ? "var" : Type ?? "?";
string valueInfo = InitialValue != null ? $" = {InitialValue}" : "";
- string modifiers = AccessModifier != null ? $"{AccessModifier} " : "";
+ string modifiers = Visibility != Visibility.Unspecified ? $"{Visibility.ToString().ToLowerInvariant()} " : "";
modifiers += IsConstant ? "const " : "";
return $"{modifiers}{typeInfo} {Name}{valueInfo}";
diff --git a/Coder/Ast/Visibility.cs b/Coder/Ast/Visibility.cs
new file mode 100644
index 0000000..fa2563d
--- /dev/null
+++ b/Coder/Ast/Visibility.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Ast;
+
+///
+/// How widely a declaration is visible: the language-agnostic form of public,
+/// protected, internal and private.
+///
+///
+/// An enumeration rather than the modifier's text, because no two target languages spell visibility
+/// the same way — C# writes a keyword, C++ groups members under an access label, JavaScript has one
+/// real modifier and Python has none at all. A string would only be right for whichever language it
+/// was typed for.
+///
+/// is the default so that a declaration nobody has given a visibility to
+/// comes out the way that language would write it anyway, rather than acquiring a modifier the
+/// author never asked for.
+///
+///
+public enum Visibility
+{
+ /// The language's own default, which is what a declaration carries until it is told otherwise.
+ Unspecified,
+
+ /// Visible to everything.
+ Public,
+
+ /// Visible to the declaring type and the types deriving from it.
+ Protected,
+
+ /// Visible within the assembly, module or package the declaration belongs to.
+ Internal,
+
+ /// Visible only within the declaring type.
+ Private,
+}
+
+///
+/// Implemented by the declarations that can carry a .
+///
+///
+/// A generator needs a member's visibility without caring which kind of member it is — C++ has to
+/// group whatever a class holds under access labels — so the property is reachable through this
+/// rather than through a switch repeated in every generator.
+///
+public interface IHasVisibility
+{
+ ///
+ /// Gets or sets how widely the declaration is visible.
+ ///
+ public Visibility Visibility { get; set; }
+}
diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs
index 89e8424..e4d82b6 100644
--- a/Coder/Languages/CSharpGenerator.cs
+++ b/Coder/Languages/CSharpGenerator.cs
@@ -45,6 +45,9 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code)
case FunctionDeclaration function:
GenerateFunction(function, code);
break;
+ case EntryPoint entryPoint:
+ GenerateEntryPoint(entryPoint, code);
+ break;
case Parameter parameter:
GenerateParameter(parameter, code);
break;
@@ -108,7 +111,7 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code)
///
private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code)
{
- code.Write($"{classDecl.AccessModifier ?? "public"} class {classDecl.Name ?? "UnnamedClass"}");
+ code.Write($"{SpellVisibility(classDecl.Visibility) ?? "public"} class {classDecl.Name ?? "UnnamedClass"}");
if (!string.IsNullOrEmpty(classDecl.BaseType))
{
@@ -127,8 +130,9 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code)
private void GenerateFunction(FunctionDeclaration function, CodeBlocker code)
{
- // Build method signature
- code.Write($"public {MapToCSType(function.ReturnType ?? "void")} {function.Name}(");
+ // Build method signature. A function nobody has given a visibility to is public: an
+ // inaccessible method is not what someone who wrote no modifier meant.
+ code.Write($"{SpellVisibility(function.Visibility) ?? "public"} {MapToCSType(function.ReturnType ?? "void")} {function.Name}(");
// Add parameters
for (int i = 0; i < function.Parameters.Count; i++)
@@ -188,9 +192,9 @@ private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocke
// A local has no access modifier and a field usually does, and the AST distinguishes the two
// by whether one was set: emitting it only when present keeps both correct.
- if (!string.IsNullOrEmpty(varDecl.AccessModifier))
+ if (SpellVisibility(varDecl.Visibility) is string modifier)
{
- code.Write($"{varDecl.AccessModifier} ");
+ code.Write($"{modifier} ");
}
// A C# constant must name its type, so an inferred one stays a plain declaration rather than
@@ -210,4 +214,33 @@ private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocke
code.WriteLine(";");
}
+
+ ///
+ /// Emits the program's entry point as C#'s Main method.
+ ///
+ /// The entry point to emit.
+ /// The writer to emit into.
+ ///
+ /// Always static, and named with the capital C# gives it. The method is emitted on its own
+ /// rather than wrapped in a class, because a C# entry point is an ordinary member of whatever
+ /// class the document puts it in — including one this generator emits around it.
+ ///
+ private void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker code)
+ {
+ code.Write($"public static {(entryPoint.ReturnsExitCode ? "int" : "void")} Main(");
+
+ if (entryPoint.AcceptsArguments)
+ {
+ code.Write("string[] args");
+ }
+
+ // The line is ended before the scope opens, so C#'s brace lands on its own line.
+ code.WriteLine(")");
+
+ using Scope body = new(code);
+ foreach (AstNode statement in entryPoint.Body)
+ {
+ GenerateInternal(statement, code);
+ }
+ }
}
diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs
index 348a621..48b03dc 100644
--- a/Coder/Languages/CppGenerator.cs
+++ b/Coder/Languages/CppGenerator.cs
@@ -72,9 +72,14 @@ protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl
///
///
- /// Every member is public. The AST carries no per-member visibility, and a C++ class defaults to
- /// private, so a generated class with no access specifier would compile to something nothing
- /// outside it could use.
+ /// Members are grouped under the access label each one asks for, and a member with no visibility
+ /// of its own lands under public:. A C++ class defaults to private, so a generated class
+ /// with no access specifier at all would compile to something nothing outside it could use.
+ ///
+ /// becomes public:: C++ has no assembly to be internal
+ /// to, and the nearest thing — a friend declaration — names the code it trusts rather than
+ /// describing a scope.
+ ///
///
protected override void GenerateClassDeclaration(ClassDeclaration classDecl, CodeBlocker code)
{
@@ -92,8 +97,101 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod
// A C++ class declaration is a statement, so its closing brace takes a semicolon.
using ScopeWithTrailingSemicolon body = new(code);
- code.WriteLine("public:");
- GenerateClassMembers(classDecl, code);
+
+ // Unspecified rather than Public, so the first member always writes its label: an unlabelled
+ // C++ class body is private, which is the one thing the label has to rule out.
+ Visibility current = Visibility.Unspecified;
+ foreach (AstNode member in classDecl.Members)
+ {
+ Visibility access = AccessOf(member);
+ if (access != current)
+ {
+ code.WriteLine($"{SpellVisibility(access)}:");
+ current = access;
+ }
+
+ if (member is VariableDeclaration field)
+ {
+ GenerateField(field, code);
+ }
+ else
+ {
+ GenerateInternal(member, code);
+ }
+ }
+ }
+
+ ///
+ /// Maps a member's visibility onto the access label C++ would put it under.
+ ///
+ /// The member to place.
+ /// The visibility whose label the member belongs beneath.
+ private static Visibility AccessOf(AstNode member) => VisibilityOf(member) switch
+ {
+ Visibility.Protected => Visibility.Protected,
+ Visibility.Private => Visibility.Private,
+ _ => Visibility.Public,
+ };
+
+ ///
+ /// Emits a variable declaration as a class member.
+ ///
+ /// The declaration to emit.
+ /// The writer to emit into.
+ ///
+ /// A constant member is emitted as static constexpr. A plain const member is a
+ /// per-instance value initialised once per object, which is not what a constant means; the
+ /// static constexpr spelling is the one that gives the class a single compile-time value,
+ /// and it is available because a constant declared here is initialised from a literal.
+ ///
+ private void GenerateField(VariableDeclaration field, CodeBlocker code)
+ {
+ if (field.IsConstant && field.InitialValue is not null)
+ {
+ code.Write("static constexpr ");
+ }
+ else if (field.IsConstant)
+ {
+ code.Write("const ");
+ }
+
+ code.Write($"{GetDeclaredType(field)} {field.Name}");
+
+ if (field.InitialValue is not null)
+ {
+ code.Write(" = ");
+ GenerateInternal(field.InitialValue, code);
+ }
+
+ EndStatement(code);
+ }
+
+ ///
+ ///
+ /// C++'s main returns int whether or not the program means to hand back an exit
+ /// code, so changes nothing in the signature — a program
+ /// that returns nothing exits with zero, which the standard supplies by falling off the end.
+ ///
+ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker code)
+ {
+ Ensure.NotNull(entryPoint);
+ Ensure.NotNull(code);
+
+ code.Write("int main(");
+
+ if (entryPoint.AcceptsArguments)
+ {
+ code.Write("int argc, char* argv[]");
+ }
+
+ // The line is ended before the scope opens, so C++'s brace lands on its own line.
+ code.WriteLine(")");
+
+ using Scope body = new(code);
+ foreach (AstNode statement in entryPoint.Body)
+ {
+ GenerateInternal(statement, code);
+ }
}
///
diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs
index baefd2f..d494d03 100644
--- a/Coder/Languages/JavaScriptGenerator.cs
+++ b/Coder/Languages/JavaScriptGenerator.cs
@@ -56,6 +56,11 @@ protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl
/// A function inside a class body is written as a method — name(args) { } — because
/// JavaScript's function keyword is a syntax error there. That is why the members are
/// emitted here rather than through .
+ ///
+ /// A private member is spelled with the # prefix, which is JavaScript's own private syntax
+ /// and enforced by the runtime. The other three visibilities have no spelling: JavaScript draws
+ /// the line at private, and a protected or internal member is an ordinary one.
+ ///
///
protected override void GenerateClassDeclaration(ClassDeclaration classDecl, CodeBlocker code)
{
@@ -101,7 +106,15 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod
/// The writer to emit into.
private void GenerateField(VariableDeclaration field, CodeBlocker code)
{
- code.Write(field.Name);
+ // `static` is how a class holds one value rather than one per instance, which is what a
+ // constant member means. JavaScript has no `const` for a field: the keyword declares a
+ // binding in a scope, and a class body is not one.
+ if (field.IsConstant)
+ {
+ code.Write("static ");
+ }
+
+ code.Write(MemberName(field.Name, field.Visibility));
if (field.InitialValue is not null)
{
@@ -119,7 +132,7 @@ private void GenerateField(VariableDeclaration field, CodeBlocker code)
/// The writer to emit into.
private void GenerateMethod(FunctionDeclaration method, CodeBlocker code)
{
- code.Write($"{method.Name ?? "unnamedMethod"}(");
+ code.Write($"{MemberName(method.Name ?? "unnamedMethod", method.Visibility)}(");
GenerateParameterList(method.Parameters, code);
code.Write(") ");
@@ -159,6 +172,58 @@ protected override void GenerateVariableDeclaration(VariableDeclaration varDecl,
EndStatement(code);
}
+ ///
+ /// Spells a class member's name for its visibility.
+ ///
+ /// The member's name in the AST.
+ /// The visibility it was declared with.
+ /// The name as the class body should spell it.
+ ///
+ /// # is a part of the name in JavaScript rather than a modifier in front of it, so private
+ /// members are spelled here rather than by writing a keyword before the declaration.
+ ///
+ private static string MemberName(string name, Visibility visibility) =>
+ visibility == Visibility.Private ? $"#{name}" : name;
+
+ ///
+ ///
+ /// JavaScript has no entry point of its own — a module runs top to bottom — so the function is
+ /// emitted along with the call that runs it. The arguments and the exit code are Node's
+ /// (process.argv, process.exit); a browser has neither, and there is nothing more
+ /// portable to reach for.
+ ///
+ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker code)
+ {
+ Ensure.NotNull(entryPoint);
+ Ensure.NotNull(code);
+
+ code.Write("function main(");
+
+ if (entryPoint.AcceptsArguments)
+ {
+ code.Write("args");
+ }
+
+ // The line is left open, so the scope's brace lands on it: JavaScript braces hang.
+ code.Write(") ");
+
+ using (Scope body = new(code))
+ {
+ foreach (AstNode statement in entryPoint.Body)
+ {
+ GenerateInternal(statement, code);
+ }
+ }
+
+ code.WriteLine();
+
+ // The call is what makes the file a program rather than a definition of one.
+ string arguments = entryPoint.AcceptsArguments ? "process.argv.slice(2)" : string.Empty;
+ code.WriteLine(entryPoint.ReturnsExitCode
+ ? $"process.exit(main({arguments}));"
+ : $"main({arguments});");
+ }
+
///
/// Maps a binary operator to its JavaScript spelling.
///
diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs
index 31b7c75..06afe9e 100644
--- a/Coder/Languages/LanguageGeneratorBase.cs
+++ b/Coder/Languages/LanguageGeneratorBase.cs
@@ -264,6 +264,7 @@ protected static bool CanGenerateStandardNodes(AstNode astNode)
// No null check: a type pattern never matches null.
return astNode is FunctionDeclaration
or ClassDeclaration
+ or EntryPoint
or Parameter
or ReturnStatement
or BinaryExpression
@@ -280,6 +281,38 @@ or AstLeafNode
or AstLeafNode;
}
+ ///
+ /// Spells a visibility as the keyword a C-family language writes in front of a declaration.
+ ///
+ /// The visibility to spell.
+ /// The keyword, or null when nothing should be written for it.
+ ///
+ /// Null rather than an empty string, so a caller writes the modifier and the space after it
+ /// together or writes neither, instead of leaving a stray space in front of a declaration that
+ /// carries no visibility. A language that spells one of them differently — or does not spell them
+ /// at all — does not call this.
+ ///
+ protected static string? SpellVisibility(Visibility visibility) => visibility switch
+ {
+ Visibility.Public => "public",
+ Visibility.Protected => "protected",
+ Visibility.Internal => "internal",
+ Visibility.Private => "private",
+ _ => null,
+ };
+
+ ///
+ /// Reads the visibility a declaration was given.
+ ///
+ /// The node to read.
+ /// Its visibility, or for a node that cannot carry one.
+ ///
+ /// Reached through so a generator grouping a class's members by
+ /// visibility does not need a switch over which kind of member each one is.
+ ///
+ protected static Visibility VisibilityOf(AstNode node) =>
+ node is IHasVisibility declaration ? declaration.Visibility : Visibility.Unspecified;
+
///
/// Escapes a string literal's contents for a target language using C-style backslash escapes.
///
diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs
index 2284857..ee45a4f 100644
--- a/Coder/Languages/PythonGenerator.cs
+++ b/Coder/Languages/PythonGenerator.cs
@@ -8,6 +8,14 @@ namespace ktsu.Coder.Languages;
///
/// Generates Python code from AST nodes.
///
+///
+/// Python has neither access modifiers nor constants, so and
+/// are deliberately dropped, the same way JavaScript
+/// drops the types the AST carries. The conventions Python does have for both — a leading underscore
+/// for a non-public member, an upper-case name for a constant — are spellings of the identifier
+/// rather than modifiers on the declaration, and renaming a declaration here would leave every
+/// to it naming something that no longer exists.
+///
public class PythonGenerator : StandardLanguageGenerator
{
///
@@ -170,6 +178,64 @@ private void GenerateMethod(FunctionDeclaration method, CodeBlocker code)
}
}
+ ///
+ ///
+ /// The function is emitted with the __main__ guard that runs it, which is how a Python
+ /// file is both a script and an importable module. Arguments and the exit code go through
+ /// sys, so the import it needs is emitted with it — the entry point is the top of a file,
+ /// which is the one place a generator can put an import without knowing what else the document
+ /// holds.
+ ///
+ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker code)
+ {
+ Ensure.NotNull(entryPoint);
+ Ensure.NotNull(code);
+
+ bool needsSys = entryPoint.AcceptsArguments || entryPoint.ReturnsExitCode;
+ if (needsSys)
+ {
+ code.WriteLine("import sys");
+ code.WriteLine();
+ code.WriteLine();
+ }
+
+ code.Write("def main(");
+
+ if (entryPoint.AcceptsArguments)
+ {
+ code.Write("args");
+ }
+
+ code.WriteLine("):");
+
+ // Python's body is delimited by indentation alone, so there is no brace scope to open.
+ using (IndentScope body = new(code))
+ {
+ if (entryPoint.Body.Count == 0)
+ {
+ code.WriteLine("pass");
+ }
+ else
+ {
+ // EndStatement writes nothing for Python, so the line break is this loop's to write.
+ foreach (AstNode statement in entryPoint.Body)
+ {
+ GenerateInternal(statement, code);
+ code.WriteLine();
+ }
+ }
+ }
+
+ // PEP 8 puts two blank lines between a top-level definition and what follows it.
+ code.WriteLine();
+ code.WriteLine();
+ code.WriteLine("if __name__ == \"__main__\":");
+
+ using IndentScope guard = new(code);
+ string call = entryPoint.AcceptsArguments ? "main(sys.argv[1:])" : "main()";
+ code.WriteLine(entryPoint.ReturnsExitCode ? $"sys.exit({call})" : call);
+ }
+
///
protected override void GenerateParameter(Parameter parameter, CodeBlocker code, int position)
{
@@ -202,6 +268,11 @@ private static string PythonTypeFromGenericType(string genericType)
}
///
+ ///
+ /// A constant is emitted as an ordinary assignment: Python 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.
+ ///
protected override void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocker code)
{
Ensure.NotNull(varDecl);
diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs
index 442c8ca..96bab06 100644
--- a/Coder/Languages/StandardLanguageGenerator.cs
+++ b/Coder/Languages/StandardLanguageGenerator.cs
@@ -55,6 +55,10 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code)
GenerateFunctionDeclaration(funcDecl, code);
break;
+ case EntryPoint entryPoint:
+ GenerateEntryPoint(entryPoint, code);
+ break;
+
case Parameter parameter:
GenerateParameter(parameter, code, 0);
break;
@@ -103,6 +107,13 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code)
/// The writer to emit into.
protected abstract void GenerateClassDeclaration(ClassDeclaration classDecl, CodeBlocker code);
+ ///
+ /// Emits the program's entry point, and whatever else the language needs in order to run it.
+ ///
+ /// The entry point to emit.
+ /// The writer to emit into.
+ protected abstract void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker code);
+
///
/// Emits the members of a class, one after another.
///
diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs
index 044251c..41d2e51 100644
--- a/Coder/Serialization/YamlDeserializer.cs
+++ b/Coder/Serialization/YamlDeserializer.cs
@@ -55,6 +55,8 @@ public YamlDeserializer()
"ClassDeclaration" => DeserializeClassDeclaration(nodeData),
"functionDeclaration" => DeserializeFunctionDeclaration(nodeData),
"FunctionDeclaration" => DeserializeFunctionDeclaration(nodeData),
+ "entryPoint" => DeserializeEntryPoint(nodeData),
+ "EntryPoint" => DeserializeEntryPoint(nodeData),
"parameter" => DeserializeParameter(nodeData),
"Parameter" => DeserializeParameter(nodeData),
"returnStatement" => DeserializeReturnStatement(nodeData ?? new object()),
@@ -122,6 +124,73 @@ private static void DeserializeFunctionBasicProperties(FunctionDeclaration funcD
{
funcDecl.ReturnType = returnTypeObj?.ToString();
}
+
+ DeserializeVisibility(funcDecl, dict);
+ }
+
+ ///
+ /// Reads a declaration's visibility, leaving it when the
+ /// document does not say.
+ ///
+ /// The declaration being read into.
+ /// The mapping the node was written as.
+ ///
+ /// accessModifier is read as well as visibility: documents written before visibility
+ /// became an enumeration spell it that way, and a saved document should not stop opening because
+ /// the library changed how it models the same idea.
+ ///
+ private static void DeserializeVisibility(IHasVisibility declaration, Dictionary