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 }

Posted in ,  | no comments

Comments

Comments are disabled