From cbfb4d115c2138ed4410099aa82e92a84146bdaa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 13:12:12 +0000 Subject: [PATCH] [major] Model visibility, constants and entry points in the AST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01FMNNvzNnuLErq96R4NjYUi --- CLAUDE.md | 5 +- Coder.Graph/AstFields.cs | 45 ++- Coder.Graph/AstNodeCatalog.cs | 2 + Coder.Graph/AstSchema.cs | 21 +- Coder.Test/Ast/ClassDeclarationTests.cs | 10 +- Coder.Test/Ast/ConstantDeclarationTests.cs | 175 ++++++++++++ Coder.Test/Ast/EntryPointTests.cs | 241 ++++++++++++++++ Coder.Test/Ast/VisibilityTests.cs | 286 +++++++++++++++++++ Coder.Test/Graph/AstFieldsTests.cs | 4 +- Coder/Ast/ClassDeclaration.cs | 8 +- Coder/Ast/EntryPoint.cs | 84 ++++++ Coder/Ast/FunctionDeclaration.cs | 10 +- Coder/Ast/VariableDeclaration.cs | 15 +- Coder/Ast/Visibility.cs | 52 ++++ Coder/Languages/CSharpGenerator.cs | 43 ++- Coder/Languages/CppGenerator.cs | 108 ++++++- Coder/Languages/JavaScriptGenerator.cs | 69 ++++- Coder/Languages/LanguageGeneratorBase.cs | 33 +++ Coder/Languages/PythonGenerator.cs | 71 +++++ Coder/Languages/StandardLanguageGenerator.cs | 11 + Coder/Serialization/YamlDeserializer.cs | 81 +++++- Coder/Serialization/YamlSerializer.cs | 52 +++- README.md | 35 ++- 23 files changed, 1397 insertions(+), 64 deletions(-) create mode 100644 Coder.Test/Ast/ConstantDeclarationTests.cs create mode 100644 Coder.Test/Ast/EntryPointTests.cs create mode 100644 Coder.Test/Ast/VisibilityTests.cs create mode 100644 Coder/Ast/EntryPoint.cs create mode 100644 Coder/Ast/Visibility.cs 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 dict) + { + if ((dict.TryGetValue("visibility", out object? visibilityObj) || dict.TryGetValue("accessModifier", out visibilityObj)) + && Enum.TryParse(visibilityObj?.ToString(), ignoreCase: true, out Visibility visibility)) + { + declaration.Visibility = visibility; + } + } + + private EntryPoint DeserializeEntryPoint(object? nodeData) + { + EntryPoint entryPoint = new(); + if (nodeData is not Dictionary dict) + { + return entryPoint; + } + + if (dict.TryGetValue("acceptsArguments", out object? argumentsObj) + && bool.TryParse(argumentsObj?.ToString(), out bool acceptsArguments)) + { + entryPoint.AcceptsArguments = acceptsArguments; + } + + if (dict.TryGetValue("returnsExitCode", out object? exitCodeObj) + && bool.TryParse(exitCodeObj?.ToString(), out bool returnsExitCode)) + { + entryPoint.ReturnsExitCode = returnsExitCode; + } + + if (dict.TryGetValue("body", out object? bodyObj) && bodyObj is List bodyList) + { + foreach (object statementObj in bodyList) + { + if (statementObj is not Dictionary statementDict) + { + continue; + } + + foreach ((object statementType, object statementData) in statementDict) + { + AstNode? statement = DeserializeNode(statementType.ToString() ?? string.Empty, statementData); + if (statement != null) + { + entryPoint.Body.Add(statement); + } + } + } + } + + DeserializeMetadata(entryPoint, dict); + + return entryPoint; } private static void DeserializeFunctionParameters(FunctionDeclaration funcDecl, Dictionary dict) @@ -185,10 +254,7 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) classDecl.BaseType = baseTypeObj?.ToString(); } - if (dict.TryGetValue("accessModifier", out object? accessObj)) - { - classDecl.AccessModifier = accessObj?.ToString(); - } + DeserializeVisibility(classDecl, dict); DeserializeClassMembers(classDecl, dict); DeserializeMetadata(classDecl, dict); @@ -237,7 +303,7 @@ private static void DeserializeMetadata(AstNode node, Dictionary private void DeserializeFunctionChildren(FunctionDeclaration funcDecl, Dictionary dict) { - HashSet knownKeys = ["name", "returnType", "parameters", "body", "metadata"]; + HashSet knownKeys = ["name", "returnType", "visibility", "accessModifier", "parameters", "body", "metadata"]; foreach ((object key, object value) in dict) { @@ -498,10 +564,7 @@ private VariableDeclaration DeserializeVariableDeclaration(object? nodeData) varDecl.IsTypeInferred = isInferred; } - if (dict.TryGetValue("accessModifier", out object? modifierObj)) - { - varDecl.AccessModifier = modifierObj.ToString(); - } + DeserializeVisibility(varDecl, dict); DeserializeMetadata(varDecl, dict); } diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index da4d6d0..a62da27 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -87,6 +87,9 @@ private static void SerializeCompositeNode(AstNode node, Dictionary 0) { nodeData["parameters"] = SerializeParameters(funcDecl.Parameters); @@ -161,6 +166,43 @@ private static void SerializeFunctionDeclaration(FunctionDeclaration funcDecl, D } } + /// + /// Writes a declaration's visibility, when it has one. + /// + /// The declaration being serialized. + /// The mapping to write into. + /// + /// Written in lower case, which is how every target language spells the modifier and how someone + /// hand-editing the YAML would expect to type it. is written + /// as nothing at all: it means the declaration carries no visibility, and a key saying so would + /// only be noise in the diff. + /// + private static void SerializeVisibility(IHasVisibility declaration, Dictionary nodeData) + { + if (declaration.Visibility != Visibility.Unspecified) + { + nodeData["visibility"] = declaration.Visibility.ToString().ToLowerInvariant(); + } + } + + private static void SerializeEntryPoint(EntryPoint entryPoint, Dictionary nodeData) + { + if (entryPoint.AcceptsArguments) + { + nodeData["acceptsArguments"] = entryPoint.AcceptsArguments; + } + + if (entryPoint.ReturnsExitCode) + { + nodeData["returnsExitCode"] = entryPoint.ReturnsExitCode; + } + + if (entryPoint.Body.Count > 0) + { + nodeData["body"] = SerializeBodyStatements(entryPoint.Body); + } + } + private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictionary nodeData) { if (classDecl.Name != null) @@ -173,10 +215,7 @@ private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictio nodeData["baseType"] = classDecl.BaseType; } - if (classDecl.AccessModifier != null) - { - nodeData["accessModifier"] = classDecl.AccessModifier; - } + SerializeVisibility(classDecl, nodeData); if (classDecl.Members.Count > 0) { @@ -338,10 +377,7 @@ private static void SerializeVariableDeclaration(VariableDeclaration varDecl, Di nodeData["isTypeInferred"] = varDecl.IsTypeInferred; } - if (varDecl.AccessModifier != null) - { - nodeData["accessModifier"] = varDecl.AccessModifier; - } + SerializeVisibility(varDecl, nodeData); } private static void SerializeAssignmentStatement(AssignmentStatement assignment, Dictionary nodeData) diff --git a/README.md b/README.md index 6069228..f5a7330 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ This makes it ideal for code generation tools, transpilers, and any application ### Supported AST Node Types - **ClassDeclaration**: A named class with an optional base type, holding methods, fields and nested classes +- **EntryPoint**: Where the program starts running, optionally reading arguments and returning an exit code - **FunctionDeclaration**: Represents function/method declarations with parameters and body - **Parameter**: Function parameters with optional default values - **ReturnStatement**: Return statements with optional expressions @@ -53,6 +54,32 @@ Increment and decrement are deliberately absent from `UnaryOperator`: Python has them, and an `AssignmentStatement` with `AssignmentOperator.AddAssign` expresses the same effect in every target language. +### Visibility + +A `ClassDeclaration`, a `FunctionDeclaration` and a `VariableDeclaration` each carry a `Visibility`: +`Public`, `Protected`, `Internal`, `Private`, or `Unspecified` — the default, meaning the +declaration is written the way the target language would write it anyway. It is an enumeration +rather than the modifier's text because no two languages spell visibility the same way: + +| Language | How it is spelled | +|---|---| +| C# | The keyword, in front of the declaration; a class or function with none is `public` | +| C++ | An access label (`public:`, `protected:`, `private:`) the members are grouped under; `Internal` becomes `public:` | +| JavaScript | A private class member takes the `#` prefix, which is JavaScript's own private syntax; nothing for the rest | +| Python | Nothing — Python has no access modifiers, and its leading-underscore convention renames the declaration rather than modifying it | + +### Constants and entry points + +A `VariableDeclaration` marked `IsConstant` with a literal `InitialValue` is a constant. C# writes +`const`, C++ writes `const` for a local and `static constexpr` for a class member, JavaScript writes +`const` for a local and `static` for a class member, and Python writes a plain assignment, having no +constant declaration to spell. + +An `EntryPoint` holds the statements a program runs. Each generator writes the spelling its language +looks for: C#'s `static Main`, C++'s free `int main`, Python's `main` with the `__main__` guard that +calls it (and the `import sys` its arguments and exit code need), and JavaScript's `main` with the +call that runs it. + ### Visual graph editor `ktsu.Coder.Graph` renders an AST as an editable node graph, built on @@ -139,10 +166,10 @@ directly. | `LanguageId` | Generator | Extension | Notes | |---|---|---|---| -| `python` | `PythonGenerator` | `py` | Type hints, `None` for void, `pass` for an empty body or class; `self` on methods | -| `csharp` | `CSharpGenerator` | `cs` | Mapped type names, `var` for inferred declarations, declared access modifiers | -| `javascript` | `JavaScriptGenerator` | `js` | Untyped; `const`/`let`; strict `===` and `!==`; method and field syntax inside a class | -| `cpp` | `CppGenerator` | `cpp` | Mapped type spellings (`str` → `std::string`); `auto` for inferred declarations; `public:` and a terminating `;` on a class | +| `python` | `PythonGenerator` | `py` | Type hints, `None` for void, `pass` for an empty body or class; `self` on methods; `main` with its `__main__` guard | +| `csharp` | `CSharpGenerator` | `cs` | Mapped type names, `var` for inferred declarations, visibility keywords, `const`, `static Main` | +| `javascript` | `JavaScriptGenerator` | `js` | Untyped; `const`/`let`; strict `===` and `!==`; method, `static` and `#private` syntax inside a class | +| `cpp` | `CppGenerator` | `cpp` | Mapped type spellings (`str` → `std::string`); `auto` for inferred declarations; access labels, `static constexpr` members and a terminating `;` on a class | ## Installation