Pimping my Editor - Refactoring, Take 1
Posted by Ben Jackson Sat, 15 Apr 2006 04:29:00 GMT
TextMate 1.5 is out, and it kicks Royal Ass. The new XML input mode is one of those things that you never even considered, but which becomes totally indispensable once you wrap your head around it. Basically, you can set a command to take as input an XML tree with all of your document's text wrapped in scope tags. In other words, you can re-use TextMate's syntax parser and manipulate the tree however you'd like. Once you're done, output the result without the tags, and you've got your original document back with your changes.
Put the following into a command, with Input set to "Entire Document" and output set to "Replace Document". See the above link for the hack that lets you get the XML scope tree.
#!/usr/local/bin/ruby
class TMUtils
def self.check_selection_for_newline
if ENV['TM_SELECTED_TEXT'].chomp == ENV['TM_SELECTED_TEXT']
ENV['TM_SELECTED_TEXT'] += "\n"
ENV['TM_LINE_NUMBER'] = (1 + ENV['TM_LINE_NUMBER'].to_i).to_s
end
end
end
module ActionScript
class SelectionReplacer
attr_reader :lines, :tab, :current_line, :selecting, :done_selecting, :cancelled
def initialize lines, selected_text
@tab = !ENV['TM_SOFT_TABS'] == 'YES' ? "\t" : " "
(ENV['TM_TAB_SIZE'].to_i - 1).times { @tab += " " } if @tab == " "
@@initialized = true
@selection_indent = ""
@lines, @selected_text = lines, selected_text
end
def process
@lines.each_index do |line_index|
@current_line = line_index
before_line
yield if @done_selecting
after_line
end
end
def get_indent line_index, min_indent=1
line_index -= 1 while @lines[line_index].length <= min_indent
@lines[line_index].match(("#{@tab}+"))
end
def check_selection
caret_at_top = strip_tags(@lines[ENV['TM_LINE_NUMBER'].to_i - 1]) == @selected_text[0]
start_selection if @current_line + @selected_text.length + 1 == ENV['TM_LINE_NUMBER'].to_i and !@selecting and !caret_at_top
start_selection if @current_line + 1 == ENV['TM_LINE_NUMBER'].to_i and !@selecting and caret_at_top
end_selection if @current_line + 1 == ENV['TM_LINE_NUMBER'].to_i and @selecting and !caret_at_top
end_selection if @current_line == ENV['TM_LINE_NUMBER'].to_i - 1 + @selected_text.length and @selecting and caret_at_top
end
def start_selection
@selecting = true
@selection_indent = get_indent(@current_line)
end
def end_selection
@selecting = false
@done_selecting = true
end
def before_line
check_selection unless @cancelled
end
def after_line
puts strip_tags(@lines[@current_line]) unless @selecting
end
def strip_tags line
line.gsub(/<.+?>/, "")
end
end
class FunctionExtractor < SelectionReplacer
@@initialized = false
attr_reader :function_added, :function_name
def initialize lines, selected_text
super lines, selected_text
dialog_result = `/usr/local/bin/CocoaDialog inputbox --title Create new Function --informative-text "Please enter the function name:" --button1 Okay --button2 Cancel`.chomp.to_a
@cancelled = true if dialog_result[0].to_i == 2
@function_name = dialog_result[1]
end
def process
super { add_function if is_function_or_comment(@lines[@current_line]) and !@function_added }
end
def start_selection
super
puts "#{get_indent(@current_line-1)}#{@lines[@current_line-1] =~ /{/ ? @tab : "" }this.#{@function_name}()"
end
def add_function
function_indent = get_indent(@current_line-1)
puts "#{function_indent}#{ARGV[1] || "private"} #{ARGV[2] ? ARGV[2] + " " : ""}function #{@function_name}()"
puts "#{function_indent}{"
puts ENV['TM_SELECTED_TEXT'].chomp.gsub(Regexp.new("^#{@selection_indent}"), function_indent.to_s + @tab)
puts "#{function_indent}}\n\n"
@function_added = true
end
def is_function_or_comment line
line =~ /<(?:entity.name.function)|(?:comment.block).+?>(.+?)<\/(?:entity.name.function)|(?:comment.block)/
end
end
end
TMUtils::check_selection_for_newline
ActionScript::FunctionExtractor.new(STDIN.readlines, ENV['TM_SELECTED_TEXT'].to_a).process
There's a hell of a lot going on there, so I'm not going to explain it all. Some tricky bits that ought to be of use for other commands:
class TMUtils
def self.check_selection_for_newline
if ENV['TM_SELECTED_TEXT'].chomp == ENV['TM_SELECTED_TEXT']
ENV['TM_SELECTED_TEXT'] += "\n"
ENV['TM_LINE_NUMBER'] = (1 + ENV['TM_LINE_NUMBER'].to_i).to_s
end
end
end
This checks the selected text to see if it has a newline at the end. If it doesn't, it adds one to the end. This way you get the same result regardless of how the user selects the text.
class SelectionReplacer
def process
# ...
def start_selection
# ...
def end_selection
I separated the code that scans for the selected text, leaving hooks that can be overridden in subclasses (inspired by the excellent AS2API project). The three hooks are process, start_selection, and end_selection. process takes a block, executing it after checking for the selection, and stripping the scope tags from the line afterwards. This is where you can check your place in the document and add lines based on what's been parsed. The other two are pretty self-explanatory.
If you want to build on this, you can just subclass the SelectionReplacer and implement your own versions of those three methods. I left hooks for before_line and after_line as well, which might come in handy. Let me know if you come up with anything interesting.
Update: Not surprisingly, Alan came up with a snappier solution which will let you replace the document with a snippet using a macro. If you download the linked bundle, add this to "Document to Snippet.bundle/Support/bin/transform.rb":
#!/usr/bin/env ruby
MARK = [0xFFFC].pack("U").freeze
def esc (txt); txt.gsub(/[$`\]/, '\\\0'); end
parts = STDIN.read.split(MARK).collect { |part| esc part }
newfunc = <<EOF
${1:private}${1/(.+)?/(?1: )/}$2${2/(.+)?/(?1: )/}function ${3:myfunction}($4)${5/(.+)?/(?1:\::)/}$5
{
EOF
newfunc += parts[1].chomp + "$0\n"
newfunc += <<EOF
}
EOF
print parts[0], parts[1].match(/\s*/), "$3();\n", parts[2].sub(/^((\s*)(?:public |private )?(?:static )?function)/) { newfunc + $1 }
