diff --git a/.github/workflows/generator-tests.yml b/.github/workflows/generator-tests.yml new file mode 100644 index 0000000000..f3d112dc0e --- /dev/null +++ b/.github/workflows/generator-tests.yml @@ -0,0 +1,34 @@ +name: GeneratorTests + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-generator-templates: + name: Check Generator Templates + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Set up Ruby + uses: ruby/setup-ruby@52753b7da854d5c07df37391a986c76ab4615999 + with: + ruby-version: "3.3" + bundler-cache: true + - name: Verify templates + run: bundle exec ./bin/generate --verify + test-generator: + name: Test Generator + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Set up Ruby + uses: ruby/setup-ruby@52753b7da854d5c07df37391a986c76ab4615999 + with: + ruby-version: "3.3" + bundler-cache: true + - name: Run tests + run: bundle exec rake test:generator diff --git a/Gemfile b/Gemfile index 581d8bfda7..ad11a6d51d 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source 'https://rubygems.org' gem 'base64' gem 'minitest' gem 'rake' +gem 'toml-rb', require: false gem 'mocha', require: false gem 'rubocop', '~> 1.50.0', require: false gem 'rubocop-minitest', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 322f49cbc3..91e05c5536 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,19 +3,20 @@ GEM specs: ast (2.4.2) base64 (0.2.0) + citrus (3.0.2) docile (1.4.0) - json (2.7.2) + json (2.8.1) minitest (5.22.3) mocha (2.1.0) ruby2_keywords (>= 0.0.5) - parallel (1.24.0) - parser (3.3.0.5) + parallel (1.26.3) + parser (3.3.6.0) ast (~> 2.4.1) racc racc (1.7.3) rainbow (3.1.1) rake (13.2.1) - regexp_parser (2.9.0) + regexp_parser (2.9.2) rexml (3.3.9) rubocop (1.50.2) json (~> 2.3) @@ -27,8 +28,8 @@ GEM rubocop-ast (>= 1.28.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.2) - parser (>= 3.3.0.4) + rubocop-ast (1.34.1) + parser (>= 3.3.1.0) rubocop-minitest (0.34.5) rubocop (>= 1.39, < 2.0) rubocop-ast (>= 1.30.0, < 2.0) @@ -42,7 +43,10 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) - unicode-display_width (2.5.0) + toml-rb (3.0.1) + citrus (~> 3.0, > 3.0) + racc (~> 1.7) + unicode-display_width (2.6.0) PLATFORMS ruby @@ -57,6 +61,7 @@ DEPENDENCIES rubocop-minitest rubocop-rake simplecov + toml-rb BUNDLED WITH 2.5.7 diff --git a/Rakefile b/Rakefile index 0c18496369..013376fdac 100644 --- a/Rakefile +++ b/Rakefile @@ -16,6 +16,21 @@ task :rubocop do system('rubocop --display-cop-names') end +desc "Run generator for specefic exercise" +task :generate, [:exercise] do |_t, argumments| + system("./bin/generate --exercise #{argumments[:exercise]}") +end + +desc "Run generator for all exercises" +task :generate_all do + system("./bin/generate --all") +end + +desc "Verify templates for all exercises" +task :verify do + system("./bin/generate --verify") +end + namespace :test do flags = ARGV.drop_while { |e| e != '--' }.drop(1).join(' ') @@ -25,5 +40,10 @@ namespace :test do task.pattern = 'test/**/*_test.rb' end + Rake::TestTask.new :generator do |task| + task.options = flags + task.pattern = 'generatorv2/test/**/*_test.rb' + end + ExerciseTestTasks.new options: flags end diff --git a/bin/generate b/bin/generate new file mode 100755 index 0000000000..23b655e0fd --- /dev/null +++ b/bin/generate @@ -0,0 +1,58 @@ +#!/usr/bin/env ruby +require 'optparse' +require 'tempfile' +require_relative '../generatorv2/lib/generator' + +# Helper methods +def exercises + Dir.entries('./exercises/practice') + .select { |file| File.directory? File.join('./exercises/practice', file) } +end + +class VerificationError < StandardError + MESSAGE = 'The result generated for %s, does not match the current file' + + def initialize(message = MESSAGE) + super + end +end + +# Parsing Code +parser = OptionParser.new + +parser.on('-v', '--version', 'Print the version') do + puts '0.1.0' +end + +parser.on('-h', '--help', 'Prints help') do + puts parser +end + +parser.on('-a', '--all', 'Generate all exercises') do + exercises.each do |exercise| + if File.exist?("./exercises/practice/#{exercise}/.meta/test_template.erb") + Generator.new(exercise).generate + end + end +end + +parser.on('--verify', 'Verify all exercises') do + exercises.each do |exercise| + if File.exist?("./exercises/practice/#{exercise}/.meta/test_template.erb") + current_code = File.read("./exercises/practice/#{exercise}/#{exercise}_test.rb") + f = File.new("./exercises/practice/#{exercise}/temp_test.rb", 'w+') + Generator.new(exercise).generate(f.path) + generated_code = f.read + File.delete(f.path) + fail VerificationError unless current_code == generated_code + end + rescue VerificationError => e + stderr.puts e.message % {exercise:} + end +end + +parser.on('-e', '--exercise EXERCISE', 'The exercise to generate') do |exercise| + Generator.new(exercise).generate +end + +parser.parse! diff --git a/exercises/practice/acronym/.meta/test_template.erb b/exercises/practice/acronym/.meta/test_template.erb new file mode 100644 index 0000000000..22d3c07863 --- /dev/null +++ b/exercises/practice/acronym/.meta/test_template.erb @@ -0,0 +1,13 @@ +require 'minitest/autorun' +require_relative 'acronym' + +class AcronymTest < Minitest::Test +<% json["cases"].each do |cases| %> + def test_<%= underscore(cases["description"]) %> + <%= status() %> + assert_equal '<%= cases["expected"] %>', <%= camel_case(json["exercise"]) %>.<%= underscore(cases["property"]) %>('<%= cases["input"]["phrase"] %>') + end +<% end %> +end + + diff --git a/exercises/practice/acronym/acronym_test.rb b/exercises/practice/acronym/acronym_test.rb index 8db62dcfb9..7dde064850 100644 --- a/exercises/practice/acronym/acronym_test.rb +++ b/exercises/practice/acronym/acronym_test.rb @@ -4,37 +4,36 @@ class AcronymTest < Minitest::Test def test_basic # skip - assert_equal "PNG", Acronym.abbreviate('Portable Network Graphics') + assert_equal 'PNG', Acronym.abbreviate('Portable Network Graphics') end def test_lowercase_words skip - assert_equal "ROR", Acronym.abbreviate('Ruby on Rails') + assert_equal 'ROR', Acronym.abbreviate('Ruby on Rails') end def test_punctuation skip - assert_equal "FIFO", Acronym.abbreviate('First In, First Out') + assert_equal 'FIFO', Acronym.abbreviate('First In, First Out') end def test_all_caps_word skip - assert_equal "GIMP", Acronym.abbreviate('GNU Image Manipulation Program') + assert_equal 'GIMP', Acronym.abbreviate('GNU Image Manipulation Program') end def test_punctuation_without_whitespace skip - assert_equal "CMOS", Acronym.abbreviate('Complementary metal-oxide semiconductor') + assert_equal 'CMOS', Acronym.abbreviate('Complementary metal-oxide semiconductor') end def test_very_long_abbreviation skip - assert_equal "ROTFLSHTMDCOALM", - Acronym.abbreviate('Rolling On The Floor Laughing So Hard That My Dogs Came Over And Licked Me') + assert_equal 'ROTFLSHTMDCOALM', Acronym.abbreviate('Rolling On The Floor Laughing So Hard That My Dogs Came Over And Licked Me') end def test_consecutive_delimiters skip - assert_equal "SIMUFTA", Acronym.abbreviate('Something - I made up from thin air') + assert_equal 'SIMUFTA', Acronym.abbreviate('Something - I made up from thin air') end end diff --git a/generatorv2/README.md b/generatorv2/README.md new file mode 100644 index 0000000000..ec18002842 --- /dev/null +++ b/generatorv2/README.md @@ -0,0 +1,134 @@ +# Generator + +Last Updated: 2024/11/9 + +The generator is a powerful tool that can be used to generate tests for exercises based on the canonical data. +The generator is written in Ruby and is located in the `bin` directory. + +## How to use the generator + +### Things to do before running the generator + +Run `bundle install` to install the required libraries. +Before running the generator you have to make sure a couple of files are in place. + +1. `tests.toml` file + +It is located under the `.meta` folder for each exercise. +The toml file is used to configure which exercises are generated and which are not. +Since the generator grabs all the data from the canonical data, so does this enable new tests that won't automatically be merged in. +Instead so does new tests have to be added to the toml file before they show up in the test file. + +If there is a test that isn't needed or something that doesn't fit Ruby you can remove it from the toml file. +By writing after the test name `include = false` and it will be skipped when generating the test file. + +2. `config.json` file, located in the root of the track + +The generator makes sure that the exercise is in the config.json so you need to add it there before running the generator. + +#### Things to note + +The script which grabs info from the toml file is quite sensitive, writing the toml file in an incorrect way can brick the generator. + +Here are some examples of how you should **NOT** work with the toml file. + +Make sure that the UUID is the only thing inside of `[UUID]`, if there is, for example, an extra space so would that break it. +Here is an example + +```toml +# This would break it since it is an extra space between UUID and `]` +[1e22cceb-c5e4-4562-9afe-aef07ad1eaf4 ] +# This would break it since it is an extra space between UUID and `[` +[ 1e22cceb-c5e4-4562-9afe-aef07ad1eaf4] +``` + +The script won't care if you write `include = true` since if it sees the UUID it will always take it as long as `include = false` is not written. +The script will not work if anything is misspelled, although the part which gets `include = false` doesn't care if it gets an extra space or not. + +**NOTE:** +You are also **NOT** allowed to write `include = false` more than once after each UUID. +Since that can lead to errors in the generator. + +Bad way: + +```toml +[1e22cceb-c5e4-4562-9afe-aef07ad1eaf4] +description = "basic" +include = false +include = false +``` + +Good way: + +```toml +[1e22cceb-c5e4-4562-9afe-aef07ad1eaf4] +description = "basic" +include = false +``` + +### Template + +The generator uses a template file to generate the test file. +The template is located under the `.meta` for each exercise. + +This template has to be manually written for each exercise. +The goal although is to make it so that you only have to write the template once and then it will be able to be used to generate new tests. + +The template file is written in [Embedded Ruby(ERB)][erb]. +ERB enables you to write Ruby code inside of the template file. +It also means that the templates can be highly customizable since you can write any Ruby code you want. + +When writing the template file, it is recommended to look at already existing template files to get a better understanding of how it works. +The template is getting a slightly modified version of the canonical data, so you can check out the [canonical data][canonical data] to see the data structure. +The modification is that the cases which are not included in the toml file will be removed from the data structure. + +When writing the template so is it a special tool that can help with giving `# skip` and `skip` tags for tests. +You simply have to call the `status` method. +It will return either `# skip` or `skip` depending on if it is the first test case or not. + +Here is an example: + +``` +<%= status()%> +<%= status()%> +<%= status()%> +``` + +result: + +``` +# skip +skip +skip +``` + +### The Test Generator + +If all the earlier steps are done so can you run the generator. +To run the generator you need to have a working Ruby installation and installed all gems in the Gemfile. +The generator is located in the `bin` directory and is called `generator.rb`. + +To run the generator so do you have to be in the root directory and run the following command: + +```shell +bundle exec ./bin/generate -e +``` + +Where `` is the same name as the slug name which is located in the `config.json` file. + +For more commands so can you run the following command: + +```shell +bundle exec ./bin/generate --help +``` + +### Errors and warnings + +The generator will give you errors and warnings if something is wrong. +That includes if the exercise is not in the `config.json` file, if the exercise is not in the toml file, or if the template file is missing. +Also if it has a problem getting the `canonical-data.json` file so will it give you an error. +The generator also uses a formatter which will give you errors if the generated file is not formatted correctly. +The file will still be generated even if formatter gives errors, therefore can you check the file and see what is wrong and fix it in the template. + +[erb]: https://docs.ruby-lang.org/en/master/ERB.html +[canonical data]: https://github.com/exercism/problem-specifications diff --git a/generatorv2/lib/generator.rb b/generatorv2/lib/generator.rb new file mode 100644 index 0000000000..042bcbda6e --- /dev/null +++ b/generatorv2/lib/generator.rb @@ -0,0 +1,48 @@ +require 'toml-rb' +require 'net/http' +require 'uri' +require 'json' +require 'erb' +require 'rubocop' +require_relative 'utils' + +class Generator + include Utils + include NullDevice + + def initialize(exercise = nil) + @first = true + @exercise = exercise + end + + def generate(result_path = "./exercises/practice/#{@exercise}/#{@exercise}_test.rb") + json = remote_files + uuid = toml("./exercises/practice/#{@exercise}/.meta/tests.toml") + additional_json(json) + json["cases"] = remove_tests(uuid, json) + status = proc { status } + template = ERB.new File.read("./exercises/practice/#{@exercise}/.meta/test_template.erb") + + result = template.result(binding) + + File.write(result_path, result) + RuboCop::CLI.new. + run(['-x', '-c', '.rubocop.yml', '-o', NullDevice.path, result_path]) + end + + def underscore(str) + str.gsub(/[-\s]/, '_').downcase + end + + def camel_case(str) + str.split(/[-_]/).map(&:capitalize).join + end + + def status + if @first + @first = false + return "# skip" + end + "skip" + end +end diff --git a/generatorv2/lib/utils.rb b/generatorv2/lib/utils.rb new file mode 100644 index 0000000000..04a9f87e4e --- /dev/null +++ b/generatorv2/lib/utils.rb @@ -0,0 +1,56 @@ +module NullDevice + def self.path + Gem.win_platform? ? 'NUL' : '/dev/null' + end +end + +module Utils + def toml(path = "./exercises/practice/#{@exercise}/.meta/tests.toml") + raise "Toml not found: #{path}" unless File.exist?(path) + + uuid = TomlRB.load_file(path) + uuid = uuid.filter do |_k, v| + v.none? { |k, inner_value| k == "include" && !inner_value } + end + uuid.keys + end + + def remote_files + url = URI.parse("https://raw.githubusercontent.com/exercism/problem-specifications/main/exercises/#{@exercise}/canonical-data.json") + response = Net::HTTP.get_response(url) + case response + when Net::HTTPSuccess + JSON.parse(response.body) + when Net::HTTPNotFound + check_for_local_canonical_data + else + raise "Error while requesting the #{@exercise} data file from GitHub... " \ + "Status was #{response.code}" + end + end + + def check_for_local_canonical_data(path = "./exercises/practice/#{@exercise}/canonical-data.json") + raise "No canonical-data.json found in #{@exercise} directory" unless File.exist?(path) + + JSON.parse(File.read(path)) + end + + def additional_json(json) + file_path = "./exercises/practice/#{@exercise}/.meta/additional_tests.json" + return unless File.exist?(file_path) + + JSON.parse(File.read(file_path))["cases"].each do |test| + json["cases"] << test + end + end + + def remove_tests(uuid, json) + json["cases"].each_with_object([]) do |x, acc| + if x["cases"] + acc << remove_tests(uuid, json) + elsif uuid.include?(x["uuid"]) + acc << x + end + end + end +end diff --git a/generatorv2/test/misc/tests.toml b/generatorv2/test/misc/tests.toml new file mode 100644 index 0000000000..5c5b9fd84e --- /dev/null +++ b/generatorv2/test/misc/tests.toml @@ -0,0 +1,31 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[1e22cceb-c5e4-4562-9afe-aef07ad1eaf4] +description = "basic" + +[79ae3889-a5c0-4b01-baf0-232d31180c08] +description = "lowercase words" + +[ec7000a7-3931-4a17-890e-33ca2073a548] +description = "punctuation" + +[32dd261c-0c92-469a-9c5c-b192e94a63b0] +description = "all caps word" + +[ae2ac9fa-a606-4d05-8244-3bcc4659c1d4] +description = "punctuation without whitespace" + +[0e4b1e7c-1a6d-48fb-81a7-bf65eb9e69f9] +description = "very long abbreviation" + +[6a078f49-c68d-4b7b-89af-33a1a98c28cc] +description = "consecutive delimiters" diff --git a/generatorv2/test/misc/tests_all_excluded.toml b/generatorv2/test/misc/tests_all_excluded.toml new file mode 100644 index 0000000000..a321ebeb17 --- /dev/null +++ b/generatorv2/test/misc/tests_all_excluded.toml @@ -0,0 +1,38 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[1e22cceb-c5e4-4562-9afe-aef07ad1eaf4] +description = "basic" +include = false + +[79ae3889-a5c0-4b01-baf0-232d31180c08] +description = "lowercase words" +include = false + +[ec7000a7-3931-4a17-890e-33ca2073a548] +description = "punctuation" +include = false + +[32dd261c-0c92-469a-9c5c-b192e94a63b0] +description = "all caps word" +include = false + +[ae2ac9fa-a606-4d05-8244-3bcc4659c1d4] +description = "punctuation without whitespace" +include = false + +[0e4b1e7c-1a6d-48fb-81a7-bf65eb9e69f9] +description = "very long abbreviation" +include = false + +[6a078f49-c68d-4b7b-89af-33a1a98c28cc] +description = "consecutive delimiters" +include = false diff --git a/generatorv2/test/misc/tests_no_include.toml b/generatorv2/test/misc/tests_no_include.toml new file mode 100644 index 0000000000..97497982ec --- /dev/null +++ b/generatorv2/test/misc/tests_no_include.toml @@ -0,0 +1,32 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[1e22cceb-c5e4-4562-9afe-aef07ad1eaf4] +description = "basic" + +[79ae3889-a5c0-4b01-baf0-232d31180c08] +description = "lowercase words" + +[ec7000a7-3931-4a17-890e-33ca2073a548] +description = "punctuation" + +[32dd261c-0c92-469a-9c5c-b192e94a63b0] +description = "all caps word" + +[ae2ac9fa-a606-4d05-8244-3bcc4659c1d4] +description = "punctuation without whitespace" + +[0e4b1e7c-1a6d-48fb-81a7-bf65eb9e69f9] +description = "very long abbreviation" +include = false + +[6a078f49-c68d-4b7b-89af-33a1a98c28cc] +description = "consecutive delimiters" diff --git a/generatorv2/test/toml_test.rb b/generatorv2/test/toml_test.rb new file mode 100644 index 0000000000..88d42c7abd --- /dev/null +++ b/generatorv2/test/toml_test.rb @@ -0,0 +1,29 @@ +require_relative '../lib/generator' +require 'minitest/autorun' + +class GeneratorTest < Minitest::Test + def test_importning_toml + assert_equal %w[1e22cceb-c5e4-4562-9afe-aef07ad1eaf4 + 79ae3889-a5c0-4b01-baf0-232d31180c08 + ec7000a7-3931-4a17-890e-33ca2073a548 + 32dd261c-0c92-469a-9c5c-b192e94a63b0 + ae2ac9fa-a606-4d05-8244-3bcc4659c1d4 + 0e4b1e7c-1a6d-48fb-81a7-bf65eb9e69f9 + 6a078f49-c68d-4b7b-89af-33a1a98c28cc], + Generator.new("two_fer").toml("generatorv2/test/misc/tests.toml") + end + + def test_importing_toml_with_no_include + assert_equal %w[1e22cceb-c5e4-4562-9afe-aef07ad1eaf4 + 79ae3889-a5c0-4b01-baf0-232d31180c08 + ec7000a7-3931-4a17-890e-33ca2073a548 + 32dd261c-0c92-469a-9c5c-b192e94a63b0 + ae2ac9fa-a606-4d05-8244-3bcc4659c1d4 + 6a078f49-c68d-4b7b-89af-33a1a98c28cc], + Generator.new("two_fer").toml("generatorv2/test/misc/tests_no_include.toml") + end + + def test_importing_toml_with_all_excluded + assert_empty Generator.new("two_fer").toml("generatorv2/test/misc/tests_all_excluded.toml") + end +end diff --git a/generatorv2/test/utils_test.rb b/generatorv2/test/utils_test.rb new file mode 100644 index 0000000000..7590322f42 --- /dev/null +++ b/generatorv2/test/utils_test.rb @@ -0,0 +1,36 @@ +require_relative '../lib/generator' +require 'minitest/autorun' + +class UtilTest < Minitest::Test + def test_camelize + assert_equal "Acronym", + Generator.new("acronym").camel_case("acronym") + end + + def test_camelize_with_two_words + assert_equal "TwoFer", + Generator.new("two-fer").camel_case("two-fer") + end + + def test_underscore + assert_equal "acronym", + Generator.new("acronym").underscore("acronym") + end + + def test_underscore_with_two_words + assert_equal "two_fer", + Generator.new("two-fer").underscore("two-fer") + end + + def test_status + assert_equal "# skip", + Generator.new("acronym").status + end + + def test_status_after_first + generator = Generator.new("acronym") + generator.status + assert_equal "skip", + generator.status + end +end