Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 3 additions & 17 deletions bin/add-practice-exercise.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 24 additions & 19 deletions generators/Exercises.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text.Json;

using Humanizer;

namespace Generators;
Expand All @@ -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<Exercise> Templated(string? slug = null) => Find(slug, hasTemplate: true);

internal static List<Exercise> Untemplated(string? slug = null) => Find(slug, hasTemplate: false);

private static List<Exercise> 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<Exercise> Parse() =>
JsonSerializer.Deserialize<JsonElement>(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));
}
3 changes: 2 additions & 1 deletion generators/Paths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
40 changes: 25 additions & 15 deletions generators/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NewOptions, GenerateOptions>(args)
.WithParsed<GenerateOptions>(HandleGenerateCommand)
.WithParsed<NewOptions>(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<Error> 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<Options>(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; }
}
}
85 changes: 85 additions & 0 deletions generators/TemplateGenerator.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => {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}}}%}
}";
}
Loading