A question came up today about how to reverse a sentence. Word by word, preserving periods etc. I’ve done stuff like this before but I really saw an opportunity to solve this contrived quiz type question with TDD. It’s perfect really. Tedious string checking? Screw it. Let my test tell me when I’m done.
Ok so I could have probably done this more comfortably in Java but it would have been more lines. I banged this out in Ruby, including “learning” test cases (it’s really easy) in about an hour. At one point I realized how easy string manipulation is in Ruby for this and I literally said, “holy shit Ruby is amazing”. And then the edge cases started happening. I was only handling periods and my algorithm fell over flat on three sentences because I was trying to do a string[start, end] when it’s really string[start, length]. I fixed it with learning the String#slice syntax.
Ok enough fanboi service. There’s two files. One’s the test and one’s the class. Run the test and not the class (ie: ruby tc_reverser.rb).
tc_reverser.rb:
require 'test/unit'
require 'reverser'
# Be wary of newlines in the test and expected trings.
# Do not try to word wrap with escapes. It's very picky.
class TestReverser < Test::Unit::TestCase
# Optional
#def setup
#end
def teardown
if @test_passed then
puts "\n #{@r.reverse}" # more verbose for successful passes
puts "#{@method_name.upcase} OK.\n\n"
else
puts "#{@method_name.upcase} FAIL.\n\n"
end
end
# simple test
def test_reverser
test = "You space bastard. You killed my pine."
expected = "Bastard space you. Pine my killed you."
@r = Reverser.new(test)
assert_equal(expected, @r.reverse, "Fail.")
end
# Test multiple sentence delimiters
def test_multiple_sentence_delimiters
test = "Shape up, man. You're a slacker. Do you want to be a slacker for the rest of your life?"
expected = "Man, up shape. Slacker a you're. Life your of rest the for slacker a be to want you do?"
@r = Reverser.new(test)
assert_equal(expected, @r.reverse, "Fail.")
end
# Test no periods, question mark and commas
def test_question_comma
test = "Then tell me, future boy, huh, who's president of the United States in 1985?"
expected = "1985 in States United the of president who's, huh, boy future, me tell then?"
@r = Reverser.new(test)
assert_equal(expected, @r.reverse, "Fail.")
end
# Big test
def test_everything
test = "Our first television set. Dad just picked it up today. Do you have a television? Well, yeah, you know, we have two of them. Wow! You must be rich. Oh, honey, he's teasing you. Nobody has two television sets."
expected = "Set television first our. Today up it picked just dad. Television a have you do? Them of two have we, know you, yeah, well. Wow! Rich be must you. You teasing he's, honey, oh. Sets television two has nobody."
@r = Reverser.new(test)
assert_equal(expected, @r.reverse, "Fail.")
end
end
I have to apologize for the scrollbars in the above posted code. I tried many different ways of escaping carriage returns for better formatting but it would have required a lot of changes to deal with the \n and so on in the tests. I had something nicely formatted working but the tabs and spaces for alignment then screwed the test and broke the pretty formatting a different way.
reverser.rb:
# A class that reverses sentences.
# "My dog has fleas." --> "Fleas has dog my."
class Reverser
# tasty constructor, full of taste
def initialize(text)
@text = text
end
def reverse
delim_location = 0 # char by char, remember !?. for sentence determination
delim_last_location = 0 # allows us to move on to the next sentence
delim_i = 0 # an iterator
new_sentence = "" # inits an empty string for string methods to work
new_sentence_array = Array.new
punc_array = Array.new # list of punctuation marks in order
@text.each_char do |char|
delim_i += 1 # iterator
if (char == "." || char == "?" || char == "!")
# we hit a deliminter (?!.), remember it
sentence = @text.slice(delim_last_location..delim_i)
sentence.strip!
new_sentence_array.push(sentence)
punc_array.push(char)
delim_last_location = delim_i # remember our substring position
end
end
i = 0
new_sentence_array.each do |s|
returned_sentence = do_reverse(s) # reverse words sentence by sentence
new_sentence += "#{returned_sentence}#{punc_array[i]}" # append the delimiter back
if (i+1 != new_sentence_array.length) # look ahead for last sentence
new_sentence += " " # only append spaces if this is not the last sentence
end
i += 1
end
new_sentence # return new_sentence
end
def do_reverse(s)
s.chop! # trim delimiter
text_array = s.split(" ") # split on spaces to array
text_array[0].downcase! # downcase first word
text_array.reverse! # destructive reverse
# need to move commas back
text_array_i = 0
text_array.each do |e|
text_array_i += 1
if e.include? ","
e.chop! # modify original array
text_array[text_array_i - 2] += ","
end
end
text_array[0].capitalize!
text_array = text_array.join(" ") # destructive join
text_array.to_s # return reversed array
end
end
When you run the test case it looks like this:
Loaded suite ~/src/ruby/tc_reverser Started Set television first our. Today up it picked just dad. Television a have you do? Them of two have we, know you, yeah, well. Wow! Rich be must you. You teasing he's, honey, oh. Sets television two has nobody. TEST_EVERYTHING OK! . Man, up shape. Slacker a you're. Life your of rest the for slacker a be to want you do? TEST_MULTIPLE_SENTENCE_DELIMITERS OK! . 1985 in States United the of president who's, huh, boy future, me tell then? TEST_QUESTION_COMMA OK! . Bastard space you. Pine my killed you. TEST_REVERSER OK! . Finished in 0.003567 seconds.
`
4 tests, 4 assertions, 0 failures, 0 errors`
Marty McFly would be proud. So, some limitations. First, I don’t handle elipsis (…) characters at all. It splits on sentence delimiters which one is a period. The eplisis would cause major weirdness. I also don’t handle recapitalizing the word “Dad” as you can see in the “test_everything” test. This would require language parsing or a massive pick list. I also don’t handle slang or abbreviations. For example, “You told ‘em?” would turn into “‘em told you?” which might not be exactly right.
There are many other edge cases that this thing would fall flat on. My test case tests what functionality I wanted and nothing more.
Ruby’s String#slice was super handy on this as well as capitalize, reverse and even a regex split that I didn’t use (amazing!). You can see it here. It would split text into an array really easily:
sentence_array = @text.split(/[\.\!\?]/) # split with regular expression
new_sentence_array = Array.new
sentence_array.each do |s|
new_sentence_array.push(do_reverse(s))
end
puts sentence_array[2]
return
But then I’d lose the delimiter character (!?. etc) and would have to save it, search it or some other nonsense. Still, really neat that you can split on a regex.