Over the past several months I have been focussing on professional work, but I’ve managed to still maintain some involvement with Exercism as version 3 continues to be developed. One of the cool things coming in version 3 is being able to run tests against a solution in the browser. There are several advantages to this but in brief:
- Lowers the bar to allow students to experiment with new languages.
- Students have instant validation on the code they write.
- Students with limited hardware or software can learn without worrying about installing/working locally.
To support this there is quite a bit of tooling being built – notably what we have come to call a “Test-runner”. The test runners are isolated containers designed to isolate un-trusted code and execute it as safely as possible. The real challenge is writing an adapter for the 50-some languages to return a machine readable format compatible for the website.
I’ve had a hand in writing a few of these:
There have been several neat challenges for each of these:
- In Elixir, implementing a custom formatter to capture the test run and save it as a json file.
- In PHP, using typescript and cheerio to parse junit output and reformat it to json.
But this past week I’ve been working with Crystal to write a test-runner. As a learning challenge, I set a goal to write all of the needed tooling in crystal.
What is Crystal
Crystal is a modern language that defines itself by a few things:
- Syntax is heavily inspired by ruby – making it easy to read and write.
- Strong typing with static type checking and compile-time type inference.
- Null reference checks.
- Macros for metaprogramming and AST manipulation
- Concurrency primitives similar to Go and Clojure.
- Native C-lib bindings.
The standard library is quite rich, and has some really nice patterns to serialize and deserialize an object to json.
So the defined interface for a test-runner states that upon completion a
results.json must be created with a summary of the test run. So in crystal that might be represented by this class structure:
class TestCase include JSON::Serializable getter name : String getter test_code : String? property status : String? property message : String? property output : String? end class TestRun include JSON::Serializable getter version : Int32 property status : String? property message : String? property tests : Array(TestCase) end
Then all that’s needed to serialize/deserialize a json file is a single LOC!
# deserialize test_run = TestRun.from_json(File.read(scaffold_json)) # serialize File.write(output_file, test_run.to_json)
Crystal’s batteries included test suite has an undocumented (as far as I could determine) ability to output the result in junit format. So by parsing the junit output, the json can be constructed with the data!
junit_file_content = File.read(junit_file) junit_document = XML.parse(junit_file_content)
junit_document’s value is an
XML::Node where you can access its name, attributes and children for cnvenient traversal and data extraction.
I would agree with Crystal’s website, it was relatively easy to pick up with regard to its syntax and how methods and values behave. I think the steepest learning curve was cleanly handling the nil-able types.
When you have a nil-able type union like
String? which represents
Nil | String, if you call a method on it (like #upcase) it will generate a compile-time error:
Suppose this code:
class Person property name : String? def initialize(@name) end end person = Person.new person.name.upcase
Now when compiled:
> crystal person.cr Showing last frame. Use --error-trace for full trace. In person.cr:6:13 6 | person.name.upcase ^----- Error: undefined method 'upcase' for Nil (compile-time type is (String | Nil))
There are several ways to handle this:
You can assert it is not nil, which will work if it actually isn’t nil, otherwise it will raise an error:
person = Person.new("Ted") person.name.not_nil!.upcase
try, which will only perform the block if the value is not nil
person = Person.new("Ted") person.name.try(&.upcase)
You can also create situations where the compiler can infer whether it is nil:
person = Person.new("Ted") name = person.name if name name.upcase end
One caveat is that if you are using a nested reference, you have to assign the value to a local variable to infer its value because otherwise in a concurrent setting there might be some race condition where it becomes null after the if statement, but before the method call.
person = Person.new("Ted") # Can't do this: if person.name person.name.upcase end # Do this: name = person.name if name name.upcase end # Or this: if name = person.name name.upcase end
Overall, Crystal was a fun programming venture. I don’t know if I will be using it regularly from here on out, but it was fun and reasonably easy to get things done. The Crystal: Getting Started are pretty good with most things covered to get started.