Seeing Crystal Clear
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.
Deserializing 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)
Parsing XML
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!
In Javascript-land, I have done this with cheerio, but Crystal even has native XML parsing abilities:
junit_file_content = File.read(junit_file)
junit_document = XML.parse(junit_file_content)
So now junit_document
’s value is an XML::Node
where you can access its name, attributes and children for cnvenient traversal and data extraction.
Difficulties
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
-
You can
try
, which will only perform the block if the value is not nilperson = 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
Wrapping up
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.