Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create CodeTag plugin to render code files with and without solutions #29

Merged
merged 14 commits into from
Aug 16, 2024
Merged
12 changes: 12 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,15 @@ require:

AllCops:
NewCops: enable

Metrics/AbcSize:
Enabled: false

Metrics/CyclomaticComplexity:
Enabled: false

Metrics/MethodLength:
Max: 100

Metrics/PerceivedComplexity:
Enabled: false
9 changes: 9 additions & 0 deletions _includes/questions/AnotherQuestion.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
6 changes: 2 additions & 4 deletions _includes/questions/another_question.md
Original file line number Diff line number Diff line change
@@ -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" %}
12 changes: 0 additions & 12 deletions _includes/questions/another_question.py

This file was deleted.

2 changes: 1 addition & 1 deletion _includes/questions/sample_question.md
Original file line number Diff line number Diff line change
@@ -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" %}
156 changes: 156 additions & 0 deletions _plugins/code_tag.rb
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 1 addition & 4 deletions spec/support/spec_summary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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
Expand Down
Loading