diff --git a/bin/add-practice-exercise.ps1 b/bin/add-practice-exercise.ps1 index 8fbfc780fc..0ea300ec9f 100644 --- a/bin/add-practice-exercise.ps1 +++ b/bin/add-practice-exercise.ps1 @@ -46,23 +46,9 @@ $project = "${exerciseDir}/${ExerciseName}.csproj" Remove-Item -Path "${exerciseDir}/UnitTest1.cs" (Get-Content -Path ".editorconfig") -Replace "\[\*\.cs\]", "[${exerciseName}.cs]" | Set-Content -Path "${exerciseDir}/.editorconfig" -# Add and run generator (this will update the tests file) -$generator = "${exerciseDir}/.meta/Generator.tpl" -Add-Content -Path $generator -Value @" -using Xunit; - -public class {{testClass}} -{ - {{for test in tests}} - [Fact{{if !for.first}}(Skip = "Remove this Skip property to run this test"){{end}}] - public void {{test.testMethod}}() - { - // TODO: implement the test - } - {{end}} -} -"@ -& dotnet run --project generators --exercise $Exercise +# Create new generator template and run generator (this will update the tests file) +& dotnet run --project generators new --exercise $Exercise +& dotnet run --project generators generate --exercise $Exercise # Output the next steps $files = Get-Content "exercises/practice/${Exercise}/.meta/config.json" | ConvertFrom-Json | Select-Object -ExpandProperty files diff --git a/generators/Exercises.cs b/generators/Exercises.cs index 6b4452d408..7d14b43bb3 100644 --- a/generators/Exercises.cs +++ b/generators/Exercises.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + using Humanizer; namespace Generators; @@ -6,25 +8,28 @@ internal record Exercise(string Slug, string Name); internal static class Exercises { - internal static Exercise[] TemplatedExercises() => - Directory.EnumerateFiles(Paths.PracticeExercisesDir, "Generator.tpl", SearchOption.AllDirectories) - .Select(templateFile => Directory.GetParent(templateFile)!.Parent!.Name) - .Select(ToExercise) - .OrderBy(exercise => exercise.Slug) - .ToArray(); - - internal static Exercise TemplatedExercise(string slug) - { - var exercise = ToExercise(slug); - - if (!Directory.Exists(Paths.ExerciseDir(exercise))) - throw new ArgumentException($"Could not find exercise '{slug}'."); - - if (!File.Exists(Paths.TemplateFile(exercise))) - throw new ArgumentException($"Could not find template file for exercise '{slug}'."); - - return exercise; - } + internal static List Templated(string? slug = null) => Find(slug, hasTemplate: true); + + internal static List Untemplated(string? slug = null) => Find(slug, hasTemplate: false); + + private static List Find(string? slug, bool hasTemplate) => + Parse() + .Where(exercise => slug is null || exercise.Slug == slug) + .Where(HasCanonicalData) + .Where(exercise => hasTemplate == HasTemplate(exercise)) + .ToList(); + + private static IEnumerable Parse() => + JsonSerializer.Deserialize(File.ReadAllText(Paths.TrackConfigFile)) + .GetProperty("exercises") + .GetProperty("practice") + .EnumerateArray() + .Select(exercise => exercise.GetProperty("slug").ToString()) + .Order() + .Select(ToExercise); private static Exercise ToExercise(string slug) => new(slug, slug.Dehumanize()); + + private static bool HasCanonicalData(Exercise exercise) => File.Exists(Paths.CanonicalDataFile(exercise)); + private static bool HasTemplate(Exercise exercise) => File.Exists(Paths.TemplateFile(exercise)); } \ No newline at end of file diff --git a/generators/Paths.cs b/generators/Paths.cs index e2cbbe7df8..be6933d207 100644 --- a/generators/Paths.cs +++ b/generators/Paths.cs @@ -6,7 +6,8 @@ internal static class Paths internal static readonly string ProbSpecsDir = Path.Join(RootDir, ".problem-specifications"); private static readonly string ProbSpecsExercisesDir = Path.Join(ProbSpecsDir, "exercises"); internal static readonly string PracticeExercisesDir = Path.Join(RootDir, "exercises", "practice"); - + internal static readonly string TrackConfigFile = Path.Join(RootDir, "config.json"); + internal static string ExerciseDir(Exercise exercise) => Path.Join(PracticeExercisesDir, exercise.Slug); internal static string TestsFile(Exercise exercise) => Path.Join(ExerciseDir(exercise), $"{exercise.Name}Tests.cs"); internal static string TestsTomlFile(Exercise exercise) => Path.Join(ExerciseDir(exercise), ".meta", "tests.toml"); diff --git a/generators/Program.cs b/generators/Program.cs index 6da9d090d0..44f4634a46 100644 --- a/generators/Program.cs +++ b/generators/Program.cs @@ -2,24 +2,34 @@ namespace Generators; -public static class Program +public static class Program { - private class Options + static void Main(string[] args) => + Parser.Default.ParseArguments(args) + .WithParsed(HandleGenerateCommand) + .WithParsed(HandleNewCommand) + .WithNotParsed(HandleErrors); + + private static void HandleGenerateCommand(GenerateOptions options) => + Exercises.Templated(options.Exercise).ForEach(TestsGenerator.Generate); + + private static void HandleNewCommand(NewOptions options) => + Exercises.Untemplated(options.Exercise).ForEach(TemplateGenerator.Generate); + + private static void HandleErrors(IEnumerable errors) => + errors.ToList().ForEach(Console.Error.WriteLine); + + [Verb("generate", isDefault: true, HelpText = "Generate the test file's contents using the exercise's generator template file.")] + private class GenerateOptions { - [Option('e', "exercise", Required = false, HelpText = "The exercise (slug) to generate the tests file for.")] + [Option('e', "exercise", Required = false, HelpText = "The exercise (slug) which tests file to generate.")] public string? Exercise { get; set; } } - - static void Main(string[] args) => - Parser.Default.ParseArguments(args) - .WithParsed(options => - { - foreach (var exercise in Exercises(options)) - TestsGenerator.Generate(exercise); - }); - private static Exercise[] Exercises(Options options) => - options.Exercise is null - ? Generators.Exercises.TemplatedExercises() - : [Generators.Exercises.TemplatedExercise(options.Exercise)]; + [Verb("new", HelpText = "Create a new exercise generator template file.")] + private class NewOptions + { + [Option('e', "exercise", Required = false, HelpText = "The exercise (slug) for which to generate a generator file.")] + public string? Exercise { get; set; } + } } \ No newline at end of file diff --git a/generators/TemplateGenerator.cs b/generators/TemplateGenerator.cs new file mode 100644 index 0000000000..3ca8fb78ba --- /dev/null +++ b/generators/TemplateGenerator.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +using Scriban; + +namespace Generators; + +internal static class TemplateGenerator +{ + internal static void Generate(Exercise exercise) + { + Console.WriteLine($"{exercise.Slug}: generating template..."); + + var canonicalData = CanonicalDataParser.Parse(exercise); + var filteredCanonicalData = TestCasesConfiguration.RemoveExcludedTestCases(canonicalData); + + var template = RenderTemplate(filteredCanonicalData); + File.WriteAllText(Paths.TemplateFile(exercise), template); + } + + private static string RenderTemplate(CanonicalData canonicalData) + { + var error = canonicalData.TestCases.Where(ExpectsError).Any(); + var testCase = canonicalData.TestCases.First(testCase => !ExpectsError(testCase)); + var model = new { error, assert = Assertion(testCase), throws = AssertThrows(testCase) }; + + var template = Template.Parse(GeneratorTemplate); + return template.Render(model).Trim() + Environment.NewLine; + } + + private static string Value(string field, JsonNode? testCase) => + testCase is not null && testCase.GetValueKind() == JsonValueKind.String + ? $"{{{{{field} | string.literal}}}}" + : $"{{{{{field}}}}}"; + + private static string Expected(JsonNode testCase) => Value("test.expected", testCase["expected"]); + + private static string Assertion(JsonNode testCase) => + testCase["expected"]!.GetValueKind() switch + { + JsonValueKind.False or JsonValueKind.True => AssertBool(testCase), + _ => AssertEqual(testCase) + }; + + private static string TestedMethodArguments(JsonNode testCase) => + string.Join(", ", testCase["input"]!.AsObject().Select(kv => Value($"test.input.{kv.Key}", kv.Value!))); + + private static string TestedMethodCall(JsonNode testCase) => + $"{{{{testedClass}}}}.{{{{test.testedMethod}}}}({TestedMethodArguments(testCase)})"; + + private static string AssertBool(JsonNode testCase) => + $"Assert.{{{{test.expected ? \"True\" : \"False\"}}}}({TestedMethodCall(testCase)});"; + + private static string AssertEqual(JsonNode testCase) => + $"Assert.Equal({Expected(testCase)}, {TestedMethodCall(testCase)});"; + + private static string AssertThrows(JsonNode testCase) => + $"Assert.Throws(() => {TestedMethodCall(testCase)});"; + + private static bool ExpectsError(this JsonNode testCase) => + testCase["expected"] is JsonObject jsonObject && jsonObject.ContainsKey("error"); + + private const string GeneratorTemplate = @" +{{if error}}using System;{{end}} +using Xunit; + +public class {%{{{testClass}}}%} +{ + {%{{{for test in tests}}}%} + [Fact{%{{{if !for.first}}}%}(Skip = ""Remove this Skip property to run this test""){%{{{end}}}%}] + public void {%{{{test.testMethod}}}%}() + { + {{-if error}} + {%{{{if test.expected.error}}}%} + {{throws}} + {%{{{else}}}%} + {{assert}} + {%{{{end}}}%} + {{-else}} + {{assert}} + {{-end}} + } + {%{{{end}}}%} +}"; +} \ No newline at end of file