[QUIZ] Code Heuristics (#172)

So, based on the sample test case, do we have to worry about Ruby 1.9 as
well? and the mixed case? That would make this test quite a bit tougher!

···

--
Posted via http://www.ruby-forum.com/.

I got lazy and thought I'd let ruby do the hard work. Given some text, I feed it through

eval("BEGIN {return true}\n#{code}", nil)

and see if an exception is raised or not (this does have limitations). It's not enough to do this line by line, for example

if foo
   puts "bar"
end

the first and last lines are not, on their own, valid ruby, but as a whole it is of course valid.

For a given chunk of text we first try and find the maximal prefix (prefix isn't quite the right word, since we only split at lines) that is valid ruby.
To do this we take an empty string and just add a line of the input at a time, running eval each time to see if what we have is valid.

if there is no such prefix, then the first line must be comment text and so we tag that line as a comment. We remove the line and repeat processing on the remaining lines.
if there is such a prefix then that prefix is tagged as code, we remove it and process the remaining lines.

The output formatter sort of does the splitting into separate files - it prints a mark to the screen where it would split (I was too lazy to start messing around with files).

What this code doesn't deal well with is lines with not much text, for example:

I think this does it:
if foo
   bar
end
Fred

The line Fred is marked as code, because that is perfectly legal ruby, it's just the value of the constant Fred. Of course that would probably blowup if you actually evaluated that line but my valid code detector can't handle that (I can't really think how you could handle this with true certainty without actually executing the code).

Sequences like

hope this helps

also look like legal code (but will produce warnings). To get around this we require that our evaling produces no warnings (and thus we trust that ruby quiz submitters squash warnings from their code :-)).
Another limitation was that if you were trying to say 'this works in ruby 1.8 but only this works in 1.9' then this solution would fail if the 1.9 code used some ruby 1.9 specific bit of syntax (obviously if the example just uses differences in the standard library that is irrelevant) and you were running this script on ruby 1.8

Fred

Usage: (example stolen from Mikael Hoilund's submission to 171)

CodeExtractor.extract(<<TEXT
Oh hi, I just thought I'd golf a solution. I'm sure other people can do a much better job than I making a full hexdumping suite, so I just had some fun. Can't seem to get it lower than 78 characters, unfortunately.

i=0;$<.read.scan(/.{0,16}/m){puts"%08x "%i+$&.unpack('H4'*8).join(' ');i+=16}

Expanded and parenthesified, clarified:

i = 0
ARGF.read.scan(/.{0,16}/m) {
puts(("%08x " % i) + $&.unpack('H4'*8).join(' '))
i += 16
}

ARGF (aliased as $<) is the file handle of all file names given in the arguments concatenated, STDIN if none — exactly what we need. The regex to scan matches between 0 and 16 characters (including newline) greedily. Change it to 1,16 if you don't want the empty line at the end.

Instead of letting the block to scan take an argument, I used a trick I picked up from the last Ruby Quiz I participated in (Obfuscated Email), and use $& inside the block, which is the last regex match. Saves two characters \o/
TEXT
)

produces as output:

#Oh hi, I just thought I'd golf a solution. I'm sure other people can do a much better job than I making a full hexdumping suite, so I just had some fun. Can't seem to get it lower than 78 characters, unfortunately.

i=0;$<.read.scan(/.{0,16}/m){puts"%08x "%i+$&.unpack('H4'*8).join(' ');i+=16}

···

On 1 Aug 2008, at 15:45, Matthew Moss wrote:

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

## Code Heuristics (#172)

This week, your task is to make my job simpler.

Each week, coders send in their submissions to Ruby Quiz problems,
usually as a mix of quiz discussion and actual code. Your task, then,
is to take a submission as input, and generate output that is the
extracted code. Every line of the input that isn't code should be
prefixed with the comment marker, `#`.

-------
#Expanded and parenthesified, clarified:

i = 0
ARGF.read.scan(/.{0,16}/m) {
puts(("%08x " % i) + $&.unpack('H4'*8).join(' '))
i += 16
}

-------
#ARGF (aliased as $<) is the file handle of all file names given in the arguments concatenated, STDIN if exactly what we need. The regex to scan matches between 0 and 16 characters (including newline) greedily. Change it to 1,16 if you don't want the empty line at the end.none

#Instead of letting the block to scan take an argument, I used a trick I picked up from the last Ruby Quiz I participated in (Obfuscated Email), and use $& inside the block, which is the last regex match. Saves two characters o/

The code:

require 'stringio'
Struct.new 'Line', :data, :code
class CodeExtractor
   attr_reader :lines, :output

   def initialize(text)
     @output =
     @lines = text.split(/[\r\n]/)
   end

   def extract
     while lines.any?
       process lines
     end
   end

   def valid_syntax?(code)
     io = StringIO.new
     original_err, $stderr= $stderr, io
     eval("BEGIN {return true}\n#{code}")
     raise 'here'
   rescue Exception
     false
   ensure
     $stderr = original_err
     return false if io.length > 0
   end

   #returns the maximum number of lines (contiguous from the start) that are valid ruby
   def valid_code_prefix_length lines
     max_valid_lines = 0
     code = ""
     lines.each_with_index do |line, index|
       code << line
       code << "\n"
       if valid_syntax? code
         max_valid_lines = index + 1
       end
     end
     return max_valid_lines
   end

   def append_output(line, code)
     @output << Struct::Line.new(line, code)
   end

   def process lines
     if (prefix_length = valid_code_prefix_length lines) > 0
       prefix_length.times { append_output lines.shift, true }
     else
       append_output lines.shift, false
     end
   end

   def format_output
     last_line = nil
     @output.each do |line|
       if line.data =~ /^\s*$/
         puts ""
         next
       end
       if last_line && last_line.code && !line.code #transition from code to comment
         puts "-------"
       end
       puts "#{line.code ? '':'#'}#{line.data}"
       last_line = line
     end
   end

   def self.extract(text)
     c= CodeExtractor.new text
     c.extract
     c.format_output
     nil
   end
end

Here is my attempt.
I made the huge assumption that most code adheres to some indentation
style. Based on that all I really had to worry about was what lines
would usually not be indented (those that start a new scope).
I also changed the prefixing of the output since lines with # might
actually be part of the code as comments.
After dealing with this in several submissions I decided to allow for
text from a copied email to be removed (usually prefixed with "> ") so
that only the submitters comments were included.
With the provided example...

172 $ ruby rubyish.rb < test.eml
[TEXT] I may have missed it, but I didn't see this solution:

[CODE] require 'enumerator'
[CODE] "Hello world!\n".enum_for(:each_byte).inject($stdout) { |res, b|
[CODE] res << b.chr
[CODE] }

[TEXT] Which in 1.9 looks much better:

[CODE] "Hello world!\n".each_char.inject($stdout) { |res, c|
[CODE] res << c
[CODE] }

[TEXT] Stefano

rubyish.rb:

#!/usr/bin/env ruby

KEYWORDS = %{
  begin do end while for in if else break redo next loop retry ensure
  rescue case when exit unless and or not class module new def raise
  puts print p
}

THRESHOLD = 0.60

def load_keywords(file)
  return KEYWORDS unless file
  keywords = []
  begin
    File.open(file).each do |line|
      keywords << line.chomp.split(/\s+/)
    end
  rescue
    keywords = KEYWORDS
  end
  keywords.flatten
end

# Determine if the line looks as though it begins/ends a block of code
def block_statement?(text)

  # Assignment is assumed to be code
  # This can lead to several false positives...keep?
  return true if text =~ /[^=]+=[^=]/

  case text
  when /^(def|class|module)/
    true
  when /^(begin|do|while|ARG.[\[\.]|for|case|if|loop|puts|print|p[( ])/
    true
  when /(\{|do) (\|.*?\|)?\s*$/
    true
  when /^(end|\})/
    true
  else
    false
  end
end

# Assume that symbols are language items. And words that match
# a set of keywords are language items. Everything else is text
# Whitespace is not considered
# Returns [# language tokens, # total tokens]
def separate(text,keywords)
  words = text.scan(/\w+/)
  s = words.join
  symbols = (text.split(//) - s.split(//)).delete_if { |x| x =~ /\s+/ }

  special = symbols.size
  total = words.size + special

  words.each { |w| special += 1 if keywords.include?(w) }

  [special,total]
end

def usage
  $stderr.puts "USAGE: #{File.basename($0)} [options] [-f <file>]"
  $stderr.puts "-"*40
  $stderr.puts " -c <prefix>"
  $stderr.puts " When text is copied from a previous email and
should be"
  $stderr.puts " removed, prefix is the start of the copied lines
(ex. -c '> ')"
  $stderr.puts " -s [name]"
  $stderr.puts " Split comment/code pairs into separate files"
  $stderr.puts " If provided, files are named name.N (Default:
submission)"
  $stderr.puts " -t <value>"
  $stderr.puts " Threshold for assuming a line is code vs.
comment."
  $stderr.puts " 0.0 - 1.0 : percent of symbols that need to be
code-like (Default: 0.60)"
  $stderr.puts " -k <keyword file>"
  $stderr.puts " File containing the whitespace-separated list of
keywords"
  $stderr.puts " to use in code-like matching"
  $stderr.puts " -p [on|off]"
  $stderr.puts " If on, prefix code output with [CODE] and comment"
  $stderr.puts " output with [TEXT]. (Default: on)"
  exit 42
end

def parse_options
  opts = {:prefix => true}
  ARGV.join.split('-').delete_if { |x| x == "" }.each do |arg|
    case arg
    when /^c(.+)/
      opts[:copied] = $1.strip
    when /^s(.+)/
      opts[:outfile] = $1.strip
    when /^t(.)/
      t = $1.strip.to_f
      raise "Invalid Threshold #{$1}" if t < 0.0 || t > 1.0
      opts[:threshold] = t
    when /^k(.+)/
      opts[:keyfile] = $1.strip
    when /^f(.+)/
      opts[:input] = $1.strip
    when /^p(.+)/
      opts[:prefix] = $1 == "on"
    else
      usage
    end
  end
  opts
end

# Begin execution

opts = parse_options
input = opts[:input] ? File.open(opts[:input]) : $stdin
keywords = load_keywords(opts[:keywords])
threshold = opts[:threshold] || THRESHOLD

# Initial classification
classified = []
input.each do |line|
  # ignore all lines copied from previous emails
  next if opts[:copied] && line =~ /^#{opts[:copied]}/
  case line
  when /^\s*$/
    classified << [:blank, line]
  when /^\s*#/, /^\s+/, /^require/
    classified << [:code, line]
  else
    classified << [:text, line]
  end
end

# Make educated guesses based on content of remaining lines
estimated = []
classified.each do |type,line|
  case type
  when :code, :blank
    estimated << [type, line]
  when :text
    if block_statement?(line)
      estimated << [:code, line]
    else
      # Compare words to 'guessed' language characters
      special,total = separate(line, keywords)
      if special.to_f/total.to_f >= threshold
        estimated << [:code,line]
      else
        estimated << [:text,line]
      end
    end
  end
end

# Assume that one line of code surrounded by two non-code lines
# is just an example and not part of the actual submission
size = estimated.size
(0...size).each do |i|
  next if i < 2
  next if i > size - 3
  a,_,b,_,type,line,y,_,z_ = estimated.slice((i-2)..(i+2))
  next unless type == :code
  next if [a,b,c,d].include? :code
  estimated[i] = [:text, line]
end

# Output modified submission
n, last, out = 0, nil, $stdout
if opts[:outfile]
  file = "%s.%d" % [opts[:outfile], n]
  out = File.open(file, 'w')
end

estimated.each do |type,line|
  case type
  when :blank
    out.puts
    next
  when :text
    if last == :code && out != $stdout
      out.close
      file = "%s.%d" % [opts[:outfile], n += 1]
      out = File.open(file, 'w')
    end
    prefix = opts[:prefix] ? " [TEXT] " : ""
    out.puts prefix + line
  when :code
    prefix = opts[:prefix] ? " [CODE] " : ""
    out.puts prefix + line
  end
  last = type
end
out.close

···

--
Posted via http://www.ruby-forum.com/.

Summary for the Code Heuristics quiz delayed; should be up tomorrow.

Apologies, things have come up that have prevented me from finishing
the summary. I'll get it done this weekend. Needless to say, there
won't be a new quiz this week.

···

On Aug 7, 11:34 am, Matthew Moss <matthew.m...@gmail.com> wrote:

Summary for the Code Heuristics quiz delayed; should be up tomorrow.