diff --git a/.rubocop.yml b/.rubocop.yml index e7b4797..69fe204 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,3 +4,15 @@ require: AllCops: NewCops: enable + +Metrics/AbcSize: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/MethodLength: + Max: 100 + +Metrics/PerceivedComplexity: + Enabled: false diff --git a/_includes/questions/AnotherQuestion.java b/_includes/questions/AnotherQuestion.java new file mode 100644 index 0000000..1a27b18 --- /dev/null +++ b/_includes/questions/AnotherQuestion.java @@ -0,0 +1,9 @@ +public class AnotherQuestion { + public static void main(String[] args) { + System.out.println("Hello world!"); + // BEGIN SOLUTION + System.out.println("solution here"); + // END SOLUTION + System.out.println("Outside solution"); + } +} diff --git a/_includes/questions/another_question.md b/_includes/questions/another_question.md index 58cc1a0..867aa7c 100644 --- a/_includes/questions/another_question.md +++ b/_includes/questions/another_question.md @@ -1,7 +1,5 @@ This is another sample question description. -{% highlight python %} -{% include questions/another_question.py %} +{% highlight java %} +{% code questions/AnotherQuestion.java false %} {% endhighlight %} - -{% include okpy.md question="another_question" %} diff --git a/_includes/questions/another_question.py b/_includes/questions/another_question.py deleted file mode 100644 index 10176de..0000000 --- a/_includes/questions/another_question.py +++ /dev/null @@ -1,12 +0,0 @@ -def another_question(a, b, c): - """ - >>> another_question(1, 2, 3) - 6 - >>> another_question(0, 0, 0) - 0 - >>> another_question(3, 0, 0) - 3 - """ - # BEGIN SOLUTION - return a + b + c - # END SOLUTION diff --git a/_includes/questions/sample_question.md b/_includes/questions/sample_question.md index bdfd88c..a8d402a 100644 --- a/_includes/questions/sample_question.md +++ b/_includes/questions/sample_question.md @@ -1,7 +1,7 @@ This is a sample question description. {% highlight python %} -{% include questions/sample_question.py %} +{% code questions/sample_question.py true %} {% endhighlight %} {% include okpy.md question="sample_question" %} diff --git a/_plugins/code_tag.rb b/_plugins/code_tag.rb new file mode 100644 index 0000000..6f303b3 --- /dev/null +++ b/_plugins/code_tag.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Jekyll + # Custom Liquid tag for including code files in _includes/ + # so that the solutions appear only when specified. + # + # Inherits from Jekyll's built-in include tag which does the heavy lifting + # of reading the file: + # https://github.com/jekyll/jekyll/blob/master/lib/jekyll/tags/include.rb + # + # TODO: figure out how to write tests for this + # TODO: automatically wrap code tags inside a highlight/endhighlight block + class CodeTag < Jekyll::Tags::IncludeTag + BEGIN_SOLUTION = 'BEGIN SOLUTION' + END_SOLUTION = 'END SOLUTION' + SUPPORTED_LANGUAGES = { + '.py': '#', + '.java': '//', + '.c': '//', + '.rb': '#', + '.go': '//', + '.sql': '--' + }.freeze + + class CodeTagError < StandardError + end + + # Expected format of this tag: + # {% code file_name.ext show_solution_boolean %} + # + # tag_name is "code" + # params is " file_name.ext show_solution_boolean " + # + # Examples: + # {% code questions/sample_question.py true %} + # {% code questions/AnotherQuestion.java page.show_solution %} + # + # NOTE: the file name must be the path relative to the _includes/ directory + def initialize(tag_name, params, tokens) + parse_params(params) + super(tag_name, @file_name, tokens) + end + + def get_extension_and_comment_chars(file_name) + SUPPORTED_LANGUAGES.each do |extension, comment_chars| + next unless file_name.end_with?(extension.to_s) + + @file_extension = extension + @comment_chars = comment_chars + # rubocop:disable Lint/NonLocalExitFromIterator + return + # rubocop:enable Lint/NonLocalExitFromIterator + end + + raise ArgumentError, + "File extension not supported: #{file_name}. Supported extensions: #{SUPPORTED_LANGUAGES.join(', ')}" + end + + def parse_params(params) + file_name, show_solution = params.strip.split + + if file_name.nil? + raise ArgumentError, + 'Missing first argument to code tag, which must be a file path relative to _includes directory' + end + + if show_solution.nil? + raise ArgumentError, + 'Missing second argument to code tag, which must be a boolean \ + representing whether solutions are displayed' + end + + get_extension_and_comment_chars(file_name) + + @file_name = file_name + @show_solution = show_solution + end + + def string_boolean?(value) + %w[true false].include?(value) + end + + def boolean?(value) + [true, false].include?(value) + end + + def to_boolean(value) + unless string_boolean?(value) + raise ArgumentError, + "value must be 'true' or 'false' not '#{value}' (type #{value.class})" + end + + value == 'true' + end + + # Parse the lines read from the file by IncludeTag, removing or keeping solutions. + # Expect that solutions, if there are any, are within a BEGIN SOLUTION and END SOLUTION + # block. For example, if @comment_chars is '//', then the solution(s) should be placed within + # // BEGIN SOLUTION and // END SOLUTION + def parse_file_lines(raw_lines) + saw_begin = false + saw_end = false + parsed_lines = [] + full_begin_solution = "#{@comment_chars} #{BEGIN_SOLUTION}" + full_end_solution = "#{@comment_chars} #{END_SOLUTION}" + + raw_lines.each_with_index do |line, index| + if line.strip == full_begin_solution + raise CodeTagError, "Duplicate '#{full_begin_solution}' at _includes/#{@file_name}:#{index + 1}" if saw_begin + + saw_begin = true + saw_end = false + elsif line.strip == full_end_solution + unless saw_begin + raise CodeTagError, + "'#{full_end_solution}' without preceding '#{full_begin_solution}' at \ + _includes/#{@file_name}:#{index + 1}" + end + + saw_begin = false + saw_end = true + elsif !saw_begin || (saw_begin && @show_solution) + parsed_lines.push(line) + end + end + + raise CodeTagError, "'#{full_begin_solution}' without matching '#{full_end_solution}'" if saw_begin && !saw_end + + parsed_lines.join("\n") + end + + # TODO: render placeholder code like "*** YOUR CODE HERE ***" if @show_solution is false? + def render(context) + # If the 2nd argument to the tag is a jekyll variable/front matter + # (rather than boolean), attempt to retrieve it + if string_boolean?(@show_solution) + @show_solution = to_boolean(@show_solution) + elsif !boolean?(@show_solution) + jekyll_variable_value = context[@show_solution] + + unless boolean?(jekyll_variable_value) + raise ArgumentError, + "Second argument to code tag must be a boolean, not \ + '#{jekyll_variable_value}' (type #{jekyll_variable_value.class})" + end + + @show_solution = jekyll_variable_value + end + + raw_lines = super.split("\n") + parse_file_lines(raw_lines) + end + end +end + +Liquid::Template.register_tag('code', Jekyll::CodeTag) diff --git a/spec/support/spec_summary.rb b/spec/support/spec_summary.rb index 0db4413..4afb42f 100644 --- a/spec/support/spec_summary.rb +++ b/spec/support/spec_summary.rb @@ -20,8 +20,6 @@ def summarize_results(results) end.flatten.tally end -# rubocop:disable Metrics/AbcSize -# rubocop:disable Metrics/MethodLength def group_results(results) all_cases_list = failing_specs(results).map do |ex| msg = ex['exception']['message'] @@ -43,9 +41,8 @@ def group_results(results) end results end -# rubocop:enable Metrics/AbcSize -# rubocop:enable Metrics/MethodLength +# rubocop:enable Metrics/AbcSize def test_failures_with_pages(summary_group) summary_group.transform_values { |list| list.map { |h| h[:page] } } end