September 16, 2018

100 Days Of Clojure Code - Day 2

Alright, so I realize that a big part of this challenge is to struggle... and do it publicly. So what I'm gonna do here is dig up one of my dark secrets. Something that I couldn't figure out and instead of asking for help, just decided to bury it down somewhere where no one would ever find it... and that way no one would ever know...

No one would ever know that I don't know how to handle exceptions.

What!?

Yes, I got stuck on something that should be so easy, and couldn't find the answer and was too embarassed to ask, and just moved on. So today, I'm gonna try to fix it. Or else struggle some more and completely embarrass myself. Then maybe someone will tell me how to do it and then I'll feel really silly.

The project is called ctrain. I wanted a way to run 4clojure problems from the terminal. Why? Well for one, I had built myself a tiny computer with a Raspberry Pi Zero:

Pi

It was inspired by Star Trek, and I made the case out of the box that the screen came in. But having just a single-core processor, and such a tiny screen, doing anything in the browser or even a graphical environment was super-awkward if not impossible. What I wanted was a cool little portable coding computer, just to do 4clojure problems and stuff. But I wanted to do it all from the command line, without even needing to start a desktop session.

First I needed a way to get the problems. There's over 100 of them and I was not about to start copying them manually. We're learning programming to make our lives easier, right? The code for the site is on Github, so I cloned the repository and got it running locally according to the instructions in the README. This is a fun thing to do, and a worthwhile exercise in dealing with databases because it uses MongoDB. This alone took me several attempts, and I think I gave up more than once. But it sure was rewarding when I finally got the db to connect.

Success! I had my very own copy of the 4clojure site running on my machine. Now to figure out how to get the problems out of the database.

I probably could have used a JSON parser, but instead I looked at the code and saw that it used CongoMongo, so I loaded it up in a REPL to try it out. It was actually pretty simple just to run a query to make it spit out all its guts, which I saved to a file.

I made myself a little convenience macro for that, by slightly modifying the pp macro:

user=> (source pp)
(defmacro pp                      
  "A convenience macro that pretty prints the last thing output. This is              
exactly equivalent to (pprint *1)."
  {:added "1.2"}
  [] `(pprint *1))
user=> (defmacro pf 
  "A convenience macro that pretty prints the last result to a file named pf.edn. "
  [] `(pprint *1 (clojure.java.io/writer "pf.edn")))

Then, when I was all done scraping the dataset, I noticed that there is already a file called data_set.clj with the problems in it!

It's OK to point and laugh at me right now.

So then I had a file with my problem data, which is a giant vector of maps:

[{:_id 1 :title "Nothing but the Truth"
    :tests ["(= __ true)"],
    :description "Complete the expression so it will evaluate to true."}
 
  {:_id 2 :title "Simple Math"
   :tests ["(= (- 10 (* 2 3)) __)"]
   :description "Innermost forms are evaluated first."} ...

So that means I could slurp it in and grab any problem by its index:

(def problems
  (read-string (slurp "problems")))
  
(defn problem [n]
  (println (str "\n#" n ": " (:title (nth problems (- n 1)))))
   (println (str "\n" (:description (nth problems (- n 1)))) "\n")
   (run! println (:tests (nth problems (- n 1))))
   (spit (str "ans-" n)(read-line)))

We print out the problem's title, description, and each of the unit tests. Then we get input from the user and save it to a file on disk.

Next, we need to replace the double-underscore (__) in the problem with the answer, evaluate it, and compare the answer to what it's supposed to be:

(defn final [results]
  (loop [coll results]
    (if (empty? coll)
      (do
        (spit "prob" (inc (read-string (slurp "prob"))))
        (println "\nGOOD JOB! Here's the next one:")
        (-main)))
      (if (= false (first coll))
            (do
              (println "\nNope... try again or Ctrl+C to quit")
              (-main)))
            (recur (rest coll))))

(defn evaluator [answers]
  (loop [totest answers results []]
    (if (empty? totest)
      (final results)
      (recur (rest totest) (conj results (eval (read-string (first totest))))))))

(defn replacer [n]
  (let [ans (slurp (str "ans-" n))]
    (loop [tests (:tests (problems (- n 1))) replaced []]
      (if (empty? tests)
        (evaluator replaced)
        (recur (rest tests) (conj replaced (s/replace (first tests) "__" ans)))))))

And it worked! But after playing with it for awhile, I noticed that something wasn't right. I tried entering a blank line, and it said it was correct! My program must be broken!

What could be causing this behavior? Eventually I figured it out.

The unit tests all ask whether the result of evaluating your answer is equal to something. And anything is equal to itself!

See:

(= 1)

So it turns out there was nothing wrong with my program, I just needed to make it reject a blank answer:

(if (= ans "")
        (do
          (println "Nice try, but blank answers are not allowed.")
          (-main)))

At this point the program totally works. That is... until you enter an undefined symbol! We are (rather dangerously) using eval on arbitrary input, so the program will crash if you forget to quote a list or something. And on a Pi Zero, firing up the JVM takes like a couple minutes. This can be avoided by running it from the REPL, of course, but this should be a simple case of catching that exception.

Unfortunately, every attempt I've made has failed, and since this is such a weird application, I don't know how to ask for help.

How do you evaluate something, without evaluating it?

4clojure uses Clojail, a library for sandboxing Clojure code. So I suppose I could use that, but for this purpose, I think that is a little bit much.

Shouldn't I be able to just use a try/catch block?

(defn safe-eval [exp]
  (try
    (eval (read-string exp))
    (catch Exception e
      (prn "Oops... Undefined symbol")
      (-main))))

Unfortunately this doesn't work, and I feel that my novice is showing real bad right now.

It catches the undefined symbol, but also the good answers!

What am I doing wrong?

See this video for an entertaining demo.

See you tomorrow!

Tags: 100 Days Of Code coding exercises KLIPSE