diff --git a/Directory.Packages.props b/Directory.Packages.props index 0a52879..55274b2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,12 +26,13 @@ + - + diff --git a/KtsuTools.CodeGen/CodeGenService.cs b/KtsuTools.CodeGen/CodeGenService.cs index 9fae40e..c30584f 100644 --- a/KtsuTools.CodeGen/CodeGenService.cs +++ b/KtsuTools.CodeGen/CodeGenService.cs @@ -3,292 +3,64 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("ktsu.KtsuTools.Test")] -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. namespace KtsuTools.CodeGen; using System.Collections.ObjectModel; -using System.Globalization; -using System.Text; -using ktsu.Semantics.Paths; -using Spectre.Console; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -#pragma warning disable CA1002 // Do not expose generic lists - needed for AST node model - -/// -/// Interface for all AST nodes. -/// -public interface IAstNode -{ - /// Gets the node type name. - public string NodeType { get; } -} - -/// -/// A function declaration AST node. -/// -public class FunctionDeclaration : IAstNode -{ - /// - public string NodeType => "functionDeclaration"; - - /// Gets or sets the function name. - public string Name { get; set; } = string.Empty; - - /// Gets or sets the return type. - public string ReturnType { get; set; } = "void"; - - /// Gets or sets the parameters. - public Collection Parameters { get; init; } = []; - - /// Gets or sets the body statements. - public Collection Body { get; init; } = []; -} - -/// -/// A parameter AST node. -/// -public class ParameterNode : IAstNode -{ - /// - public string NodeType => "parameter"; - - /// Gets or sets the parameter name. - public string Name { get; set; } = string.Empty; - - /// Gets or sets the type. - public string Type { get; set; } = string.Empty; - - /// Gets or sets a value indicating whether this parameter is optional. - public bool IsOptional { get; set; } - - /// Gets or sets the default value. - public string? DefaultValue { get; set; } -} - -/// -/// A variable declaration AST node. -/// -public class VariableDeclaration : IAstNode -{ - /// - public string NodeType => "variableDeclaration"; - - /// Gets or sets the variable name. - public string Name { get; set; } = string.Empty; - - /// Gets or sets the type. - public string? Type { get; set; } - - /// Gets or sets the initial value expression. - public string? InitialValue { get; set; } - - /// Gets or sets a value indicating whether this is a constant. - public bool IsConstant { get; set; } -} - -/// -/// A return statement AST node. -/// -public class ReturnStatement : IAstNode -{ - /// - public string NodeType => "returnStatement"; - /// Gets or sets the return expression. - public string? Expression { get; set; } -} - -/// -/// Interface for language code generators. -/// -public interface ILanguageGenerator -{ - /// Gets the language identifier. - public string LanguageId { get; } - - /// Gets the display name. - public string DisplayName { get; } - - /// Gets the file extension. - public string FileExtension { get; } - - /// Generates code from an AST. - public string Generate(FunctionDeclaration declaration); -} - -/// -/// C# code generator. -/// -public class CSharpGenerator : ILanguageGenerator -{ - /// - public string LanguageId => "csharp"; - - /// - public string DisplayName => "C#"; - - /// - public string FileExtension => "cs"; - - /// - public string Generate(FunctionDeclaration declaration) - { - Ensure.NotNull(declaration); - - StringBuilder sb = new(); - string returnType = MapType(declaration.ReturnType); - string parameters = string.Join(", ", declaration.Parameters.Select(FormatParameter)); - - sb.AppendLine(CultureInfo.InvariantCulture, $"public {returnType} {declaration.Name}({parameters})"); - sb.AppendLine("{"); - - foreach (IAstNode statement in declaration.Body) - { - AppendStatement(sb, statement); - } - - sb.AppendLine("}"); - return sb.ToString(); - } - - private static string FormatParameter(ParameterNode p) - { - string paramType = MapType(p.Type); - string defaultVal = p.IsOptional && p.DefaultValue is not null - ? $" = {p.DefaultValue}" - : string.Empty; - return $"{paramType} {p.Name}{defaultVal}"; - } - - private static void AppendStatement(StringBuilder sb, IAstNode statement) - { - if (statement is ReturnStatement ret) - { - sb.AppendLine(CultureInfo.InvariantCulture, $" return {ret.Expression};"); - } - else if (statement is VariableDeclaration varDecl) - { - string varType = varDecl.Type is not null ? MapType(varDecl.Type) : "var"; - string init = varDecl.InitialValue is not null ? $" = {varDecl.InitialValue}" : string.Empty; - string keyword = varDecl.IsConstant ? "const " : string.Empty; - sb.AppendLine(CultureInfo.InvariantCulture, $" {keyword}{varType} {varDecl.Name}{init};"); - } - } +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using ktsu.Coder.Serialization; +using ktsu.Semantics.Paths; - private static string MapType(string type) => type switch - { - "str" or "string" => "string", - "int" => "int", - "float" => "float", - "double" => "double", - "bool" => "bool", - "void" => "void", - _ => type, - }; -} +using Spectre.Console; /// -/// Python code generator. +/// Generates source from a YAML description of an abstract syntax tree. /// -public class PythonGenerator : ILanguageGenerator +/// +/// The AST, the YAML on both sides of it and every generator come from +/// ktsu.Coder. This module used to carry its own +/// — an IAstNode, five node types, a hand-written YAML reader and a C# and a Python emitter, +/// all of which that package already had along with C++, JavaScript, a round trip back to YAML, and +/// a test suite. What is left here is what a command-line front end is actually for: finding the +/// file, choosing the generator, and putting the result somewhere. +/// +public class CodeGenService { - /// - public string LanguageId => "python"; - - /// - public string DisplayName => "Python"; - - /// - public string FileExtension => "py"; - - /// - public string Generate(FunctionDeclaration declaration) - { - Ensure.NotNull(declaration); - - StringBuilder sb = new(); - string parameters = string.Join(", ", declaration.Parameters.Select(FormatParameter)); - string returnHint = declaration.ReturnType != "void" - ? $" -> {MapType(declaration.ReturnType)}" - : string.Empty; - - sb.AppendLine(CultureInfo.InvariantCulture, $"def {declaration.Name}({parameters}){returnHint}:"); - - if (declaration.Body.Count == 0) - { - sb.AppendLine(" pass"); - } - else - { - foreach (IAstNode statement in declaration.Body) - { - AppendStatement(sb, statement); - } - } - - return sb.ToString(); - } - - private static string FormatParameter(ParameterNode p) - { - string typeHint = MapType(p.Type); - string defaultVal = p.IsOptional && p.DefaultValue is not null - ? $" = {p.DefaultValue}" - : string.Empty; - return $"{p.Name}: {typeHint}{defaultVal}"; - } - - private static void AppendStatement(StringBuilder sb, IAstNode statement) - { - if (statement is ReturnStatement ret) - { - sb.AppendLine(CultureInfo.InvariantCulture, $" return {ret.Expression}"); - } - else if (statement is VariableDeclaration varDecl) + /// + /// The generators, by the name a caller asks for them under. + /// + private static readonly ReadOnlyDictionary Generators = + new(new Dictionary(StringComparer.OrdinalIgnoreCase) { - string init = varDecl.InitialValue ?? "None"; - sb.AppendLine(CultureInfo.InvariantCulture, $" {varDecl.Name} = {init}"); - } - } + ["csharp"] = new CSharpGenerator(), + ["cpp"] = new CppGenerator(), + ["javascript"] = new JavaScriptGenerator(), + ["python"] = new PythonGenerator(), + }); - private static string MapType(string type) => type switch - { - "int" => "int", - "string" or "str" => "str", - "bool" => "bool", - "float" or "double" => "float", - "void" => "None", - _ => type, - }; -} - -/// -/// Service for generating code from YAML AST definitions. -/// -public class CodeGenService -{ - private static readonly Dictionary Generators = new(StringComparer.OrdinalIgnoreCase) - { - ["csharp"] = new CSharpGenerator(), - ["python"] = new PythonGenerator(), - }; + /// + /// Gets the languages this command can generate. + /// + public static IEnumerable Languages => Generators.Keys; /// /// Generates code from a YAML AST definition file. /// /// Absolute path to the YAML AST input file. - /// Target language identifier (e.g. "csharp", "python"). + /// Target language identifier (e.g. "csharp", "cpp"). /// Optional absolute path to write generated code to. If null, writes to console. /// Cancellation token. /// Exit code (0 for success). -#pragma warning disable CA1822 // Mark members as static - instance method required for DI injection + /// + /// CA1822 and S2325 are the same complaint from two analyzers, and the answer to both is that + /// this service is a singleton injected into CodeGenCommand's constructor: a static + /// method here would leave the command holding a dependency it takes and never uses. + /// +#pragma warning disable CA1822, S2325 // Mark members as static public async Task GenerateAsync(AbsoluteFilePath inputFile, string language, AbsoluteFilePath? outputFile = null, CancellationToken ct = default) -#pragma warning restore CA1822 +#pragma warning restore CA1822, S2325 { Ensure.NotNull(inputFile); Ensure.NotNull(language); @@ -310,147 +82,66 @@ public async Task GenerateAsync(AbsoluteFilePath inputFile, string language AnsiConsole.MarkupLine($"[bold]Code Generation[/] - {generator.DisplayName}"); - // Read and parse YAML - string yamlContent = await File.ReadAllTextAsync(fullPath, ct).ConfigureAwait(false); + string yaml = await File.ReadAllTextAsync(fullPath, ct).ConfigureAwait(false); - FunctionDeclaration? function = ParseYaml(yamlContent); + if (!TryRead(yaml, out AstNode? node)) + { + return 1; + } - if (function is null) + // A generator says whether it can write a node rather than throwing partway through one, so + // a file naming something the target has no form for is reported here instead of arriving + // as half a file. + if (!generator.CanGenerate(node!)) { - AnsiConsole.MarkupLine("[red]Error: Could not parse YAML as a function declaration.[/]"); + AnsiConsole.MarkupLine( + $"[red]Error: {generator.DisplayName.EscapeMarkup()} cannot generate a {node!.GetNodeTypeName().EscapeMarkup()}.[/]"); return 1; } - // Generate code - string generatedCode = generator.Generate(function); + string generated = generator.Generate(node!); - // Output if (outputFile is not null) { string outputPath = outputFile.ToString(); - await File.WriteAllTextAsync(outputPath, generatedCode, ct).ConfigureAwait(false); + await File.WriteAllTextAsync(outputPath, generated, ct).ConfigureAwait(false); AnsiConsole.MarkupLine($"[green]Generated code written to: {outputPath.EscapeMarkup()}[/]"); + return 0; } - else - { - AnsiConsole.Write(new Panel(generatedCode.EscapeMarkup()) - .Header($"[blue]{generator.DisplayName} Output[/]") - .Border(BoxBorder.Rounded)); - } - - return 0; - } - - private static FunctionDeclaration? ParseYaml(string yamlContent) - { - try - { - IDeserializer deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - Dictionary root = deserializer.Deserialize>(yamlContent); - - if (root.TryGetValue("functionDeclaration", out object? funcObj) && funcObj is Dictionary funcDict) - { - return ParseFunctionDeclaration(funcDict); - } - - return null; - } - catch (Exception ex) when (ex is YamlDotNet.Core.YamlException or InvalidCastException or KeyNotFoundException) - { - AnsiConsole.MarkupLine($"[yellow]YAML parsing warning: {ex.Message.EscapeMarkup()}[/]"); - return null; - } - } - - private static FunctionDeclaration ParseFunctionDeclaration(Dictionary dict) - { - FunctionDeclaration func = new() - { - Name = GetStringValue(dict, "name") ?? "unnamed", - ReturnType = GetStringValue(dict, "returnType") ?? "void", - }; - - ParseParameters(dict, func); - ParseBody(dict, func); - return func; - } - - private static void ParseParameters(Dictionary dict, FunctionDeclaration func) - { - if (!dict.TryGetValue("parameters", out object? paramsObj) || paramsObj is not List paramsList) - { - return; - } + AnsiConsole.Write(new Panel(generated.EscapeMarkup()) + .Header($"[blue]{generator.DisplayName} Output[/]") + .Border(BoxBorder.Rounded)); - foreach (object paramObj in paramsList) - { - if (paramObj is Dictionary paramDict) - { - func.Parameters.Add(new ParameterNode - { - Name = GetStringValue(paramDict, "name") ?? "arg", - Type = GetStringValue(paramDict, "type") ?? "string", - IsOptional = GetBoolValue(paramDict, "isOptional"), - DefaultValue = GetStringValue(paramDict, "defaultValue"), - }); - } - } + return 0; } - private static void ParseBody(Dictionary dict, FunctionDeclaration func) + /// + /// Reads the AST a document describes, reporting rather than throwing when it describes none. + /// + /// The document. + /// The AST it described. + /// when one was read. + internal static bool TryRead(string yaml, out AstNode? node) { - if (!dict.TryGetValue("body", out object? bodyObj) || bodyObj is not List bodyList) - { - return; - } + node = null; - foreach (object stmtObj in bodyList) + try { - if (stmtObj is Dictionary stmtDict) - { - IAstNode? node = ParseStatement(stmtDict); - if (node is not null) - { - func.Body.Add(node); - } - } + node = new YamlDeserializer().Deserialize(yaml); } - } - - private static IAstNode? ParseStatement(Dictionary dict) - { - if (dict.ContainsKey("returnStatement") && dict["returnStatement"] is Dictionary retDict) + catch (Exception ex) when (ex is YamlDotNet.Core.YamlException or InvalidCastException or ArgumentException) { - return new ReturnStatement - { - Expression = GetStringValue(retDict, "expression"), - }; + AnsiConsole.MarkupLine($"[red]Error: could not read the document: {ex.Message.EscapeMarkup()}[/]"); + return false; } - if (dict.ContainsKey("variableDeclaration") && dict["variableDeclaration"] is Dictionary varDict) + if (node is null) { - return new VariableDeclaration - { - Name = GetStringValue(varDict, "name") ?? "x", - Type = GetStringValue(varDict, "type"), - InitialValue = GetStringValue(varDict, "initialValue"), - IsConstant = GetBoolValue(varDict, "isConstant"), - }; + AnsiConsole.MarkupLine("[red]Error: the document does not describe an AST node.[/]"); + return false; } - return null; + return true; } - - private static string? GetStringValue(Dictionary dict, string key) => - dict.TryGetValue(key, out object? value) ? value?.ToString() : null; - - private static bool GetBoolValue(Dictionary dict, string key) => - dict.TryGetValue(key, out object? value) && value is bool b && b; } - -#pragma warning restore CA1002 diff --git a/KtsuTools.CodeGen/KtsuTools.CodeGen.csproj b/KtsuTools.CodeGen/KtsuTools.CodeGen.csproj index 1b96e05..1304cae 100644 --- a/KtsuTools.CodeGen/KtsuTools.CodeGen.csproj +++ b/KtsuTools.CodeGen/KtsuTools.CodeGen.csproj @@ -8,6 +8,7 @@ + diff --git a/KtsuTools.Test/CodeGenServiceTests.cs b/KtsuTools.Test/CodeGenServiceTests.cs index 275bb28..7c996d5 100644 --- a/KtsuTools.Test/CodeGenServiceTests.cs +++ b/KtsuTools.Test/CodeGenServiceTests.cs @@ -2,68 +2,132 @@ namespace KtsuTools.Test; +using ktsu.Coder.Ast; +using ktsu.Semantics.Paths; using KtsuTools.CodeGen; +/// +/// What the codegen command does with a document, now that the AST, the reader and every +/// generator belong to ktsu.Coder. +/// +/// +/// These no longer test a generator — that package tests its own, across four languages and far +/// more thoroughly than a copy here ever did. What is left to test is the part this module still +/// owns: that a document reaches a generator, that the right one is chosen, and that a document +/// which describes nothing is reported rather than thrown. +/// [TestClass] public class CodeGenServiceTests { - private static FunctionDeclaration BuildSampleFunction() => new() - { - Name = "Add", - ReturnType = "int", - Parameters = - [ - new ParameterNode { Name = "a", Type = "int" }, - new ParameterNode { Name = "b", Type = "int" }, - ], - Body = - [ - new ReturnStatement { Expression = "a + b" }, - ], - }; + /// A function, in the shape Coder's reader expects. + private const string SampleYaml = + """ + functionDeclaration: + name: Add + returnType: int + parameters: + - name: a + type: int + - name: b + type: int + """; + /// + /// The document reaches an AST rather than a bespoke parser's idea of one. + /// [TestMethod] - public void CSharpGeneratorGeneratesSignatureBodyAndReturn() + public void ADocumentIsReadAsAnAst() { - CSharpGenerator generator = new(); - string code = generator.Generate(BuildSampleFunction()); - StringAssert.Contains(code, "public int Add(int a, int b)"); - StringAssert.Contains(code, "return a + b;"); - StringAssert.Contains(code, "{"); - StringAssert.Contains(code, "}"); + Assert.IsTrue(CodeGenService.TryRead(SampleYaml, out AstNode? node)); + + FunctionDeclaration function = (FunctionDeclaration)node!; + + Assert.AreEqual("Add", function.Name); + Assert.HasCount(2, function.Parameters); } + /// + /// A document that is well-formed YAML and describes no node is an error to report, not an + /// exception to let out of a command. + /// [TestMethod] - public void CSharpGeneratorMapsPythonStrToString() - { - CSharpGenerator generator = new(); - FunctionDeclaration fn = new() - { - Name = "Greet", - ReturnType = "str", - Parameters = [new ParameterNode { Name = "name", Type = "str" }], - }; - string code = generator.Generate(fn); - StringAssert.Contains(code, "public string Greet(string name)"); - } + public void ADocumentDescribingNoNodeIsReported() => + Assert.IsFalse(CodeGenService.TryRead("somethingElse: 3", out _)); + /// + /// A document that is not YAML at all is the same kind of answer. + /// [TestMethod] - public void PythonGeneratorGeneratesDefSignatureAndReturn() + public void ADocumentThatIsNotYamlIsReported() => + Assert.IsFalse(CodeGenService.TryRead("\t- : :\n bad", out _)); + + /// + /// The four languages the command offers are the four ktsu.Coder has. Two of them — + /// C++ and JavaScript — had no generator here at all before. + /// + [TestMethod] + public void EveryLanguageCoderHasIsOffered() => + Assert.AreEqual( + "cpp, csharp, javascript, python", + string.Join(", ", CodeGenService.Languages.Order(StringComparer.Ordinal))); + + /// + /// End to end: a file in, a file out, in the language asked for. + /// + [TestMethod] + public async Task GeneratingWritesTheRequestedLanguageToTheOutputFile() { - PythonGenerator generator = new(); - string code = generator.Generate(BuildSampleFunction()); - StringAssert.Contains(code, "def Add"); - StringAssert.Contains(code, "return a + b"); + string directory = Path.Combine(Path.GetTempPath(), $"codegen-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + try + { + string input = Path.Combine(directory, "add.yaml"); + string output = Path.Combine(directory, "add.hpp"); + await File.WriteAllTextAsync(input, SampleYaml, TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + int exit = await new CodeGenService().GenerateAsync( + AbsoluteFilePath.Create(input), + "cpp", + AbsoluteFilePath.Create(output), + TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.AreEqual(0, exit); + Assert.Contains("int Add(int a, int b)", await File.ReadAllTextAsync(output, TestContext.CancellationTokenSource.Token).ConfigureAwait(false)); + } + finally + { + Directory.Delete(directory, recursive: true); + } } + /// + /// A language nobody has a generator for is an exit code and a list, not a crash. + /// [TestMethod] - public void GeneratorsReportConsistentLanguageMetadata() + public async Task AnUnknownLanguageIsRefused() { - CSharpGenerator csharp = new(); - PythonGenerator python = new(); - Assert.AreEqual("csharp", csharp.LanguageId); - Assert.AreEqual("cs", csharp.FileExtension); - Assert.AreEqual("python", python.LanguageId); - Assert.AreEqual("py", python.FileExtension); + string directory = Path.Combine(Path.GetTempPath(), $"codegen-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + try + { + string input = Path.Combine(directory, "add.yaml"); + await File.WriteAllTextAsync(input, SampleYaml, TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + int exit = await new CodeGenService().GenerateAsync( + AbsoluteFilePath.Create(input), + "cobol", + ct: TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.AreEqual(1, exit); + } + finally + { + Directory.Delete(directory, recursive: true); + } } + + /// Gets or sets the context the test platform supplies. + public TestContext TestContext { get; set; } = null!; } diff --git a/README.md b/README.md index b988f25..d41c7cd 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Flags belong after a `--` separator, otherwise `ktools` claims them as its own o | `markdown` | `KtsuTools.Markdown` | Markdown processing and linting — `lint` | | `memfrag` | `KtsuTools.MemFrag` | Memory fragmentation analysis | | `project` | `KtsuTools.Project` | Project and solution operations — `build`, `clean` | -| `codegen` | `KtsuTools.CodeGen` | Code generation utilities | +| `codegen` | `KtsuTools.CodeGen` | Generates C#, C++, JavaScript or Python from a YAML syntax tree, through [ktsu.Coder](https://github.com/ktsu-dev/Coder) | | `image` | `KtsuTools.Image` | Batch image processing and icon normalization | | `explorer` | `KtsuTools.FileExplorer` | Interactive file browsing | | `build-monitor` | `KtsuTools.BuildMonitor` | CI/CD build status monitoring |