Skip to content

Commit 1720a5a

Browse files
Generate templates (#2364)
* Allow specifying commands * Add basic command structure * More generator work * More work * Refactor * Start parsing * More work * Not required * Fix null * Sort exercises * Use untemplated * Only work on exercises with canonical data * Fix generating single exercise * Some fixes * Fix leap template
1 parent 8e80fb7 commit 1720a5a

File tree

5 files changed

+139
-52
lines changed

5 files changed

+139
-52
lines changed

bin/add-practice-exercise.ps1

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,9 @@ $project = "${exerciseDir}/${ExerciseName}.csproj"
4646
Remove-Item -Path "${exerciseDir}/UnitTest1.cs"
4747
(Get-Content -Path ".editorconfig") -Replace "\[\*\.cs\]", "[${exerciseName}.cs]" | Set-Content -Path "${exerciseDir}/.editorconfig"
4848

49-
# Add and run generator (this will update the tests file)
50-
$generator = "${exerciseDir}/.meta/Generator.tpl"
51-
Add-Content -Path $generator -Value @"
52-
using Xunit;
53-
54-
public class {{testClass}}
55-
{
56-
{{for test in tests}}
57-
[Fact{{if !for.first}}(Skip = "Remove this Skip property to run this test"){{end}}]
58-
public void {{test.testMethod}}()
59-
{
60-
// TODO: implement the test
61-
}
62-
{{end}}
63-
}
64-
"@
65-
& dotnet run --project generators --exercise $Exercise
49+
# Create new generator template and run generator (this will update the tests file)
50+
& dotnet run --project generators new --exercise $Exercise
51+
& dotnet run --project generators generate --exercise $Exercise
6652

6753
# Output the next steps
6854
$files = Get-Content "exercises/practice/${Exercise}/.meta/config.json" | ConvertFrom-Json | Select-Object -ExpandProperty files

generators/Exercises.cs

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Text.Json;
2+
13
using Humanizer;
24

35
namespace Generators;
@@ -6,25 +8,28 @@ internal record Exercise(string Slug, string Name);
68

79
internal static class Exercises
810
{
9-
internal static Exercise[] TemplatedExercises() =>
10-
Directory.EnumerateFiles(Paths.PracticeExercisesDir, "Generator.tpl", SearchOption.AllDirectories)
11-
.Select(templateFile => Directory.GetParent(templateFile)!.Parent!.Name)
12-
.Select(ToExercise)
13-
.OrderBy(exercise => exercise.Slug)
14-
.ToArray();
15-
16-
internal static Exercise TemplatedExercise(string slug)
17-
{
18-
var exercise = ToExercise(slug);
19-
20-
if (!Directory.Exists(Paths.ExerciseDir(exercise)))
21-
throw new ArgumentException($"Could not find exercise '{slug}'.");
22-
23-
if (!File.Exists(Paths.TemplateFile(exercise)))
24-
throw new ArgumentException($"Could not find template file for exercise '{slug}'.");
25-
26-
return exercise;
27-
}
11+
internal static List<Exercise> Templated(string? slug = null) => Find(slug, hasTemplate: true);
12+
13+
internal static List<Exercise> Untemplated(string? slug = null) => Find(slug, hasTemplate: false);
14+
15+
private static List<Exercise> Find(string? slug, bool hasTemplate) =>
16+
Parse()
17+
.Where(exercise => slug is null || exercise.Slug == slug)
18+
.Where(HasCanonicalData)
19+
.Where(exercise => hasTemplate == HasTemplate(exercise))
20+
.ToList();
21+
22+
private static IEnumerable<Exercise> Parse() =>
23+
JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(Paths.TrackConfigFile))
24+
.GetProperty("exercises")
25+
.GetProperty("practice")
26+
.EnumerateArray()
27+
.Select(exercise => exercise.GetProperty("slug").ToString())
28+
.Order()
29+
.Select(ToExercise);
2830

2931
private static Exercise ToExercise(string slug) => new(slug, slug.Dehumanize());
32+
33+
private static bool HasCanonicalData(Exercise exercise) => File.Exists(Paths.CanonicalDataFile(exercise));
34+
private static bool HasTemplate(Exercise exercise) => File.Exists(Paths.TemplateFile(exercise));
3035
}

generators/Paths.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ internal static class Paths
66
internal static readonly string ProbSpecsDir = Path.Join(RootDir, ".problem-specifications");
77
private static readonly string ProbSpecsExercisesDir = Path.Join(ProbSpecsDir, "exercises");
88
internal static readonly string PracticeExercisesDir = Path.Join(RootDir, "exercises", "practice");
9-
9+
internal static readonly string TrackConfigFile = Path.Join(RootDir, "config.json");
10+
1011
internal static string ExerciseDir(Exercise exercise) => Path.Join(PracticeExercisesDir, exercise.Slug);
1112
internal static string TestsFile(Exercise exercise) => Path.Join(ExerciseDir(exercise), $"{exercise.Name}Tests.cs");
1213
internal static string TestsTomlFile(Exercise exercise) => Path.Join(ExerciseDir(exercise), ".meta", "tests.toml");

generators/Program.cs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,34 @@
22

33
namespace Generators;
44

5-
public static class Program
5+
public static class Program
66
{
7-
private class Options
7+
static void Main(string[] args) =>
8+
Parser.Default.ParseArguments<NewOptions, GenerateOptions>(args)
9+
.WithParsed<GenerateOptions>(HandleGenerateCommand)
10+
.WithParsed<NewOptions>(HandleNewCommand)
11+
.WithNotParsed(HandleErrors);
12+
13+
private static void HandleGenerateCommand(GenerateOptions options) =>
14+
Exercises.Templated(options.Exercise).ForEach(TestsGenerator.Generate);
15+
16+
private static void HandleNewCommand(NewOptions options) =>
17+
Exercises.Untemplated(options.Exercise).ForEach(TemplateGenerator.Generate);
18+
19+
private static void HandleErrors(IEnumerable<Error> errors) =>
20+
errors.ToList().ForEach(Console.Error.WriteLine);
21+
22+
[Verb("generate", isDefault: true, HelpText = "Generate the test file's contents using the exercise's generator template file.")]
23+
private class GenerateOptions
824
{
9-
[Option('e', "exercise", Required = false, HelpText = "The exercise (slug) to generate the tests file for.")]
25+
[Option('e', "exercise", Required = false, HelpText = "The exercise (slug) which tests file to generate.")]
1026
public string? Exercise { get; set; }
1127
}
12-
13-
static void Main(string[] args) =>
14-
Parser.Default.ParseArguments<Options>(args)
15-
.WithParsed(options =>
16-
{
17-
foreach (var exercise in Exercises(options))
18-
TestsGenerator.Generate(exercise);
19-
});
2028

21-
private static Exercise[] Exercises(Options options) =>
22-
options.Exercise is null
23-
? Generators.Exercises.TemplatedExercises()
24-
: [Generators.Exercises.TemplatedExercise(options.Exercise)];
29+
[Verb("new", HelpText = "Create a new exercise generator template file.")]
30+
private class NewOptions
31+
{
32+
[Option('e', "exercise", Required = false, HelpText = "The exercise (slug) for which to generate a generator file.")]
33+
public string? Exercise { get; set; }
34+
}
2535
}

generators/TemplateGenerator.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Nodes;
3+
4+
using Scriban;
5+
6+
namespace Generators;
7+
8+
internal static class TemplateGenerator
9+
{
10+
internal static void Generate(Exercise exercise)
11+
{
12+
Console.WriteLine($"{exercise.Slug}: generating template...");
13+
14+
var canonicalData = CanonicalDataParser.Parse(exercise);
15+
var filteredCanonicalData = TestCasesConfiguration.RemoveExcludedTestCases(canonicalData);
16+
17+
var template = RenderTemplate(filteredCanonicalData);
18+
File.WriteAllText(Paths.TemplateFile(exercise), template);
19+
}
20+
21+
private static string RenderTemplate(CanonicalData canonicalData)
22+
{
23+
var error = canonicalData.TestCases.Where(ExpectsError).Any();
24+
var testCase = canonicalData.TestCases.First(testCase => !ExpectsError(testCase));
25+
var model = new { error, assert = Assertion(testCase), throws = AssertThrows(testCase) };
26+
27+
var template = Template.Parse(GeneratorTemplate);
28+
return template.Render(model).Trim() + Environment.NewLine;
29+
}
30+
31+
private static string Value(string field, JsonNode? testCase) =>
32+
testCase is not null && testCase.GetValueKind() == JsonValueKind.String
33+
? $"{{{{{field} | string.literal}}}}"
34+
: $"{{{{{field}}}}}";
35+
36+
private static string Expected(JsonNode testCase) => Value("test.expected", testCase["expected"]);
37+
38+
private static string Assertion(JsonNode testCase) =>
39+
testCase["expected"]!.GetValueKind() switch
40+
{
41+
JsonValueKind.False or JsonValueKind.True => AssertBool(testCase),
42+
_ => AssertEqual(testCase)
43+
};
44+
45+
private static string TestedMethodArguments(JsonNode testCase) =>
46+
string.Join(", ", testCase["input"]!.AsObject().Select(kv => Value($"test.input.{kv.Key}", kv.Value!)));
47+
48+
private static string TestedMethodCall(JsonNode testCase) =>
49+
$"{{{{testedClass}}}}.{{{{test.testedMethod}}}}({TestedMethodArguments(testCase)})";
50+
51+
private static string AssertBool(JsonNode testCase) =>
52+
$"Assert.{{{{test.expected ? \"True\" : \"False\"}}}}({TestedMethodCall(testCase)});";
53+
54+
private static string AssertEqual(JsonNode testCase) =>
55+
$"Assert.Equal({Expected(testCase)}, {TestedMethodCall(testCase)});";
56+
57+
private static string AssertThrows(JsonNode testCase) =>
58+
$"Assert.Throws<ArgumentException>(() => {TestedMethodCall(testCase)});";
59+
60+
private static bool ExpectsError(this JsonNode testCase) =>
61+
testCase["expected"] is JsonObject jsonObject && jsonObject.ContainsKey("error");
62+
63+
private const string GeneratorTemplate = @"
64+
{{if error}}using System;{{end}}
65+
using Xunit;
66+
67+
public class {%{{{testClass}}}%}
68+
{
69+
{%{{{for test in tests}}}%}
70+
[Fact{%{{{if !for.first}}}%}(Skip = ""Remove this Skip property to run this test""){%{{{end}}}%}]
71+
public void {%{{{test.testMethod}}}%}()
72+
{
73+
{{-if error}}
74+
{%{{{if test.expected.error}}}%}
75+
{{throws}}
76+
{%{{{else}}}%}
77+
{{assert}}
78+
{%{{{end}}}%}
79+
{{-else}}
80+
{{assert}}
81+
{{-end}}
82+
}
83+
{%{{{end}}}%}
84+
}";
85+
}

0 commit comments

Comments
 (0)