07.07.06
Ruby Tutorials for TextMate hackers, part 3
In the previous tutorial we saw how to load some basic libraries and interact with the user using those. That post was not so much about Ruby as it was about TextMate. In this post we return to talking about Ruby, in particular some of its methods for manipulating strings. Along the way, we’ll see how to use the txmt: scheme.
Moving About the Place
We’ll start by implementing an extremely simple and useless command, before we proceed to more specialized topics. This command will simply take us to the beginning of whatever document in current. In order to do that, we’ll use the txmt: scheme. The txmt scheme is similar to the http:, mailto: and file: schemes, with one key difference: TextMate is the one handling it. Right now this scheme supports exactly one operation: To tell TextMate to open a particular file and to optionally place the caret at a particular line and column. The following line would do that:
txmt://open?url=file://pathtofile&line=5&column=3
This will open the file prescribed by pathtofile, and place the caret at the line 5 and column 3. If the url part is omitted, then it operates on the current file. So in our case, we would need to trigger the scheme:
txmt://open?line=1&column=1
To see that this works, just click on this link, and you should be taken to the beginning of the file that you have currently open in TextMate.
Okay, now the question is how to trigger this. Here a shell command comes in handy. It is called open, and it just opens whatever it is being fed with whatever application is supposed to handle it. To see how this works, open Terminal.app and type the following command, followed by return:
open "txmt://open?line=1&column=1"
The quotes are not strictly speaking necessary here, but they don’t hurt, and they would come in handy if there where spaces somewhere inside them.
So we have a shell command that could do our bidding, but how do we use it from within Ruby in TextMate? Well this is what backticks can do for us. Create a new command with input nothing, output to discard and with text:
#!/usr/bin/env ruby
`open "txmt://open?line=1&column=1"`
The backticks tell Ruby: Run the command inside them as if it was in the shell. And so it does! Give it a try.
Remember when we said that all things in Ruby have a return value? The same applies here. The backticks return whatever the command would have output if you were to run it form a terminal. For instance, if you run in Ruby the following:
d = `date`
Then d will be a string containing something like: Fri Jul 7 18:20:57 CDT 2006.
Scan Me!
Now that we know how to tell TextMate to move the caret to a particular location in the current file, let’s try to create a slightly more clever command. This command does the following: It looks in the current line for the first occurence of something of the form [n], where n is an integer. It then proceeds to locate the first occurance of that [n] in the text, and to move the caret there. If it doesn’t find a [n] in the current line, it shows a tooltip explaining its failure.
Ok, let’s do this one step at a time. We’ll first make sure that we get to much the right thing from the current line. Let’s set the input to the command as “Selection or Line”, and the output to “Show as Tool Tip”. Before we see the correct code, we’ll see how to get some feedback from Ruby as to the values of various things. The way I like to do that is to use the function , pp which stands for “pretty-print”. To use it you need to load the file that it is in. So here is an example of using it to find out what the scan methods for string objects does:
#!/usr/bin/env ruby
require 'pp'
line = STDIN.read
matches = line.scan(/\[\d+\]/)
pp matches
Let’s review this command. First require 'pp' loads the stuff necessary to use the pp command. Then, STDIN.read reads the current line and stores it in the line variable. We then call the scan method on line. The scan method tells its string to scan itself for all appearances of its argument, and then create a list of all those and return that. The argument can be either a string to search for, or a regular expression. Here we are using a regular expression, looking for a bracket (\[) followed by one or more numbers (\d+), followed by a closing bracket (\]). Finally, we are pretty-printing the matches array.
Try to run this command with a cursor on various lines, with or without things like [234], [1] and so on, and see the output. You’ll see things like: [], ["[234]","[1]"] and so on. These are arrays, the first one being empty, the second one having two elements and so on. Just to get a better understanding, try replacing the regular expression with this one: /(\[)(\d+)(\])/.
So here is a command that looks at the first match if there is one:
#!/usr/bin/env ruby
matches = STDIN.read.scan(/\[\d+\]/)
unless matches.empty? then
print matches[0]
else
print "No matches found in current line"
end
There are two new things here. The one is matches.empty? which returns true if the matches array is empty and false otherwise. The other is the matches[0] method, which returns the first entry in the array. So the message you should see is either the second print statement if there was no match or the first match if there was one.
Taking the Global View
Remember our original goal. We wanted to first find the number in the current line, and then to locate where that number shows up for the first time in the text. In order to locate where the number shows up, we need to have access to the entire file. So change the input to “Entire Document”. Of course now we have to manually locate the line that the caret is on. Luckily TextMate makes that easier for us. The variable we want here is TM_LINE_NUMBER. Here is a command that would print out the current line, given as input the entire document:
#!/usr/bin/env ruby
text = STDIN.read
line_no = ENV['TM_LINE_NUMBER'].to_i
print text.split("\n")[line_no-1]
ENV['TM_LINE_NUMBER'].to_i looks at the value of the environment variable, and converts it to an integer (to_i), since it is originally a string. Then text.split("\n")[line_no-1] splits the text along newlines, and returns the line_no-1‘th element of the resulting array. The -1 is there because Ruby counts lines starting at 0, while TextMate counts the lines starting at 1.
Ok, now we are ready for the entire command. Here it is:
#!/usr/bin/env ruby
require ENV['TM_SUPPORT_PATH'] + '/lib/exit_codes.rb'
text = STDIN.read
line_no = ENV['TM_LINE_NUMBER'].to_i
line = text.split("\n")[line_no-1]
matches = line.scan(/\[\d+\]/)
TextMate.exit_show_tool_tip "No matches found!" if matches.empty?
location = text.index(matches[0])
text_before = text.slice(0..location)
lines = text_before.split("\n")
row = lines.length
column = lines.last.length
link = "txmt://open?line=#{row}&column=#{column}"
`open "#{link}"`
Set its input to the “Entire Document” and output to “Discard”. Let’s go through the new stuff. There are a couple of things we haven’t seen before. The first one is text.index(matches[0]). The index command accepts as an argument either a string or a regular expression, and it returns the index, standing from 0, where the first match occurs, or nil if there was no match. For instance "abc".index("b") will return 1. So in our case what this does is treat the entire document as one big string, and find the first place where our string (the one that we got from looking at /\[\d+\]/) matches.
The next line is text_before = text.slice(0..location). 0..location stands for the range of entries from the beginning to location, and slice tells it to copy part of text and return it. So text_before contains the text up to the point where the string we were looking for matched. We will use this information to locate the line and column number where we want to end up. We first split this text to get an array of all the lines involved. The lines.length call returns the number of lines, which is the row number we want. Then, lines.last returns the last element in the array. This is the actual text of the line we want to end up at, up to the point we want to be at. We call length on that string, to return us how many elements the string has. One thing you should know is that this will not count properly for general unicode characters. We can work around that, but that’s for another day. Finally, we create the link string, and call open on it.
Until next time
Well, that’s it for now. Here are some exercises to keep you going until next time:
- Create a command that asks the user to type in a word. If the word has less than five characters, then exit. Otherwise, find all locations in the file where the word appears, and show an HTML window with a list of txmt links to all these words.
- Create a command that finds all numbers in the current line and adds them up, showing the answer as a tool tip.
- Write a command that finds all words in the current and computes their number and the average of their lengths.
- Write a command that uses the command line tool
lsto get a list of all files in the current directory, offer a pop-up for the user to pick one of them, and usetxmtto open that file. - Modify the command we created to just exit without calling
openin the case where the new location is where we actually are.