eigenclass logo
MAIN  Index  Search  Changes  PageRank  Login

Enhanced xmp code evaluation and annotation

Update

update.png A new version of the xmp filter is available. It can also be used to generate unit test assertions given a working codebase needing to be tested.


Motivation

When I was writing Changes in Ruby 1.9, I had to evaluate lots of small snippets under ruby 1.8 and 1.9, both to illustrate the differences and to verify if some particular change described in the changelogs still applied --- many were reverted later. I wanted to do it all from vim and hence looked into gotoken's xmp and the derived version found in rubygarden. The latter would fail for some examples, forcing me to suspend vim, evaluate the snippet with ruby and ruby19, and finally paste the results... Way too much work.

So I wrote a new xmp-style filter, hopefully more robust than the original one, and more featureful. This is what you get:

  • you can indicate which lines have to be annotated with the result value
  • the values for multiple runs of a given line are aggregated
  • new annotations corresponding to the warnings Ruby issued during parsing or execution
  • if an exception is raised, it will be collected and displayed
  • the stdout output is also collected

Example

This filter will turn

# specify which values you want with # =>
RUBY_VERSION # => 
a = @a
class Foo
  def baz(n)
    (1..n).inject do |s,x|
      s + x   # =>
    end
  end

  def bar(x)
    x.gsub(/foo/, "bar") # =>
  end
  
  1+1 # =>
end

a = Foo.new
a.bar("this is a foo") # =>
b = a.bar("this foo is foo") # =>
a.baz(4)
puts b
Foo.new.bar

into

# specify which values you want with # =>
RUBY_VERSION # => "1.8.3"
a = @a # !> instance variable @a not initialized
class Foo
  def baz(n)
    (1..n).inject do |s,x|
      s + x   # => 3, 6, 10
    end
  end

  def bar(x)
    x.gsub(/foo/, "bar") # => "this is a bar", "this bar is bar"
  end
  
  1+1 # => 2
end

a = Foo.new
a.bar("this is a foo") # => "this is a bar"
b = a.bar("this foo is foo") # => "this bar is bar"
a.baz(4)
puts b
Foo.new.bar
# ~> -:23:in `bar': wrong number of arguments (0 for 1) (ArgumentError)
# ~> 	from -:23
# >> "this bar is bar\n"

Needless to say, this is quite useful for ruby-talk postings, for instance. Or for cheap and dirty debugging/testing, to verify what is going on inside a tight loop for instance. It takes but a keypress given the right keyboard mappings (see below).

The code

On to the code:


xmp.rb

#!/usr/bin/env ruby
require 'open3'

MARKER = "!XMP#{Time.new.to_i}_#{rand(1000000)}!"
XMPRE = Regexp.new("^" + Regexp.escape(MARKER) + '\[([0-9]+)\] => (.*)')
VAR = "_xmp_#{Time.new.to_i}_#{rand(1000000)}"
WARNING_RE = /-:([0-9]+): warning: (.*)/
interpreter = ARGV.shift || "ruby"
code = ARGF.read

idx = 0
newcode = code.gsub(/^(.*) # =>.*/) do |l|
  expr = $1
  (/^\s*#/ =~ l) ? l :
  %!((#{VAR} = (#{expr}); $stderr.puts("#{MARKER}[#{idx+=1}] => " + #{VAR}.inspect) || #{VAR}))!
end
stdin, stdout, stderr = Open3::popen3(interpreter, "-w")
stdin.puts newcode
stdin.close
output = stderr.readlines

results = Hash.new{|h,k| h[k] = []}
output.grep(XMPRE).each do |line|
  result_id, result = XMPRE.match(line).captures
  results[result_id.to_i] << result
end

idx = 0
annotated = code.gsub(/^(.*) # =>.*/) do |l|
  expr = $1
  (/^\s*#/ =~ l) ? l : "#{expr} # => " + (results[idx+=1] || []).join(", ")
end.gsub(/ # !>.*/, '').gsub(/# (>>|~>)[^\n]*\n/m, "");

warnings = {}
output.join.grep(WARNING_RE).map do |x|
  md = WARNING_RE.match(x)
  warnings[md[1].to_i] = md[2]
end
idx = 0
annotated = annotated.map do |line|
  w = warnings[idx+=1]
  w ? (line.chomp + " # !> #{w}") : line
end
puts annotated
output.reject!{|x| /^-:[0-9]+: warning/.match(x)}
if exception = /^-:[0-9]+:.*/m.match(output.join)
  puts exception[0].map{|line| "# ~> " + line }
end
if (s = stdout.read) != ""
  puts "# >> #{s.inspect}" 
end


As you can see, it uses popen3 so it won't work on win32. It wouldn't take much to make it use sockets for instance, reopening stderr and stdout in a BEGIN block. Christian Neukirchen, whom I showed an earlier version of the above script, came up with the idea of generating a script which collects the results and outputs the annotated sources itself. Unfortunately that won't work with syntactically incorrect inputs, forcing one to add a ruby -c phase, which is more than I felt like coding at the time. Alternatively, if a -e 'BEGIN{ }' parameter is passed to the Ruby interpreter, the line numbers in warnings and exceptions will be off by one (or more).

Vim mappings

I'm currently using the following keyboard mappings for vim:

   map <silent> <F9> !xmp.rb ruby19<cr>
   nmap <silent> <F9> V<F9>
   imap <silent> <F9> <ESC><F9>a
   
   map <silent> <F10> !xmp.rb ruby<cr>
   nmap <silent> <F10> V<F10>
   imap <silent> <F10> <ESC><F10>a

F9 evaluates with ruby19, F10 with ruby; you can use them in normal, insert and visual modes.