#!/usr/bin/ruby
# -*- coding: utf-8 -*-
#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
# textmate_import.rb --- import textmate snippets
# 
# Copyright (C) 2009 Rob Christie, 2010 João Távora
# 
# This is a quick script to generate YASnippets from TextMate Snippets.
#
# I based the script off of a python script of a similar nature by
# Jeff Wheeler: http://nokrev.com
# http://code.nokrev.com/?p=snippet-copier.git;a=blob_plain;f=snippet_copier.py
#
# Use textmate_import.rb --help to get usage information.

require 'rubygems'
require 'plist'
require 'choice'
require 'fileutils'
require 'shellwords' # String#shellescape
require 'ruby-debug' if $DEBUG

Choice.options do
  header ''
  header 'Standard Options:'

  option :bundle_dir do
    short '-d'
    long '--bundle-dir=PATH'
    desc 'Tells the program the directory to find the TextMate bundle directory'
    default '.'
  end

  option :output_dir do
    short '-o'
    long '--output-dir=PATH'
    desc 'What directory to write the new YASnippets to'
  end

  option :snippet do
    short '-f'
    long '--file=SNIPPET FILE NAME'
    desc 'A specific snippet that you want to copy or a glob for various files'
    default '*.{tmSnippet,tmCommand,plist,tmMacro}'
  end

  option :print_pretty do
    short '-p'
    long '--pretty-print'
    desc 'Pretty prints multiple snippets when printing to standard out'
  end

  option :quiet do
    short '-q'
    long '--quiet'
    desc 'Be quiet.'
  end

  option :convert_bindings do
    short '-b'
    long '--convert-bindings'
    desc "TextMate \"keyEquivalent\" keys are translated to YASnippet \"# binding :\" directives"
  end

  option :info_plist do
    short '-g'
    long '--info-plist=PLIST'
    desc "Specify a plist file derive menu information from defaults to \"bundle-dir\"/info.plist"
  end

  separator ''
  separator 'Common options: '

  option :help do
    long '--help'
    desc 'Show this message'
  end
end

# Represents and is capable of outputting the representation of a
# TextMate menu in terms of `yas/define-menu'
#
class TmSubmenu

  @@excluded_items = [];
  def self.excluded_items; @@excluded_items; end
  
  attr_reader :items, :name
  def initialize(name, hash)
    @items = hash["items"]
    @name = name
  end

  def to_lisp(allsubmenus,
              deleteditems,
              indent = 0,
              thingy = ["(", ")"])
    
    first = true;

    string = ""
    separator_useless = true;
    items.each do |uuid|
      if deleteditems.index(uuid)
        $stderr.puts "#{uuid} has been deleted!"
        next
      end
      string += "\n"
      string += " " * indent
      string += (first ? thingy[0] : (" " * thingy[0].length))

      submenu = allsubmenus[uuid]
      snippet = TmSnippet::snippets_by_uid[uuid]
      unimplemented = TmSnippet::unknown_substitutions["content"][uuid]
      if submenu
        str = "(yas/submenu "
        string += str + "\"" + submenu.name + "\"" 
        string += submenu.to_lisp(allsubmenus, deleteditems,
                                  indent + str.length + thingy[0].length)
      elsif snippet and not unimplemented
        string += ";; " + snippet.name + "\n"
        string += " " * (indent + thingy[0].length)
        string += "(yas/item \"" + uuid + "\")"
        separator_useless = false;
      elsif snippet and unimplemented  
        string += ";; Ignoring " + snippet.name + "\n"
        string += " " * (indent + thingy[0].length)
        string += "(yas/ignore-item \"" + uuid + "\")"
        separator_useless = true;
      elsif (uuid =~ /---------------------/)
        string += "(yas/separator)" unless separator_useless
      end
      first = false;
    end
    string += ")"
    string += thingy[1]

    return string
  end

  def self.main_menu_to_lisp (parsed_plist, modename)
    mainmenu = parsed_plist["mainMenu"]
    deleted  = parsed_plist["deleted"]
    
    root = TmSubmenu.new("__main_menu__", mainmenu)
    all = {}
    
    mainmenu["submenus"].each_pair do |k,v|
      all[k] = TmSubmenu.new(v["name"], v)
    end

    excluded = mainmenu["excludedItems"] + TmSubmenu::excluded_items
    closing = "\n                    '("
    closing+= excluded.collect do |uuid|
      "\"" + uuid + "\"" 
    end.join(  "\n                       ") + "))"

    str = "(yas/define-menu "
    return str + "'#{modename}" + root.to_lisp(all,
                                               deleted,
                                               str.length,
                                               ["'(" , closing])
  end
end


# Represents a textmate snippet
#
# - @file is the .tmsnippet/.plist file path relative to cwd
# 
# - optional @info is a Plist.parsed info.plist found in the bundle dir
#
# - @@snippets_by_uid is where one can find all the snippets parsed so
#   far.
# 
# 
class SkipSnippet < RuntimeError; end
class TmSnippet
  @@known_substitutions = {
    "content"   => {
      "${TM_RAILS_TEMPLATE_START_RUBY_EXPR}"   => "<%= ",
      "${TM_RAILS_TEMPLATE_END_RUBY_EXPR}"     => " %>",
      "${TM_RAILS_TEMPLATE_START_RUBY_INLINE}" => "<% ",
      "${TM_RAILS_TEMPLATE_END_RUBY_INLINE}"   => " -%>",
      "${TM_RAILS_TEMPLATE_END_RUBY_BLOCK}"    => "end" ,
      "${0:$TM_SELECTED_TEXT}"                 => "${0:`yas/selected-text`}",
      /\$\{(\d+)\}/                            => "$\\1",
      "${1:$TM_SELECTED_TEXT}"                 => "${1:`yas/selected-text`}",
      "${2:$TM_SELECTED_TEXT}"                 => "${2:`yas/selected-text`}",
      '$TM_SELECTED_TEXT'                     => "`yas/selected-text`",
      %r'\$\{TM_SELECTED_TEXT:([^\}]*)\}'       => "`(or (yas/selected-text) \"\\1\")`",
      %r'`[^`]+\n[^`]`'                        => Proc.new {|uuid, match| "(yas/multi-line-unknown " + uuid + ")"}},
    "condition" => {
      /^source\..*$/ => "" },
    "binding"   => {},
    "type"      => {}
  }

  def self.extra_substitutions; @@extra_substitutions; end
  @@extra_substitutions = {
    "content"   => {},
    "condition" => {},
    "binding"   => {},
    "type"      => {}
  }
  
  def self.unknown_substitutions; @@unknown_substitutions; end
  @@unknown_substitutions = {
    "content"   => {},
    "condition" => {},
    "binding"   => {},
    "type"      => {}
  }

  @@snippets_by_uid={}
  def self.snippets_by_uid; @@snippets_by_uid; end

  def initialize(file,info=nil)
    @file    = file
    @info    = info
    @snippet = TmSnippet::read_plist(file)
    @@snippets_by_uid[self.uuid] = self;
    raise SkipSnippet.new "not a snippet/command/macro." unless (@snippet["scope"] || @snippet["command"])
    raise SkipSnippet.new "looks like preferences."if @file =~ /Preferences\//
    raise RuntimeError.new("Cannot convert this snippet #{file}!") unless @snippet;
  end

  def name
    @snippet["name"]
  end

  def uuid
    @snippet["uuid"]
  end

  def key
    @snippet["tabTrigger"]
  end

  def condition
    yas_directive "condition"
  end

  def type
    override = yas_directive "type"
    if override
      return override
    else
      return "# type: command\n" if @file =~ /(Commands\/|Macros\/)/
    end
  end

  def binding
    yas_directive "binding"
  end

  def content
    known = @@known_substitutions["content"]
    extra = @@extra_substitutions["content"]
    if direct = extra[uuid]
      return direct
    else
      ct = @snippet["content"]
      if ct
        known.each_pair do |k,v|
          if v.respond_to? :call
            ct.gsub!(k) {|match| v.call(uuid, match)}
          else
            ct.gsub!(k,v)
          end
        end
        extra.each_pair do |k,v|
          ct.gsub!(k,v)
        end
        # the remaining stuff is an unknown substitution
        # 
        [ %r'\$\{ [^/\}\{:]* / [^/]* / [^/]* / [^\}]*\}'x ,
          %r'\$\{[^\d][^}]+\}',
          %r'`[^`]+`',
          %r'\$TM_[\w_]+',
          %r'\(yas/multi-line-unknown [^\)]*\)'
        ].each do |reg|
          ct.scan(reg) do |match|
            @@unknown_substitutions["content"][match] = self
          end
        end
        return ct
      else
        @@unknown_substitutions["content"][uuid] = self
        TmSubmenu::excluded_items.push(uuid)
        return "(yas/unimplemented)"
      end
    end
  end

  def to_yas
    doc = "# -*- mode: snippet -*-\n"
    doc << (self.type || "")
    doc << "# uuid: #{self.uuid}\n"
    doc << "# key: #{self.key}\n" if self.key
    doc << "# contributor: Translated from textmate snippet by PROGRAM_NAME\n"
    doc << "# name: #{self.name}\n"
    doc << (self.binding || "")
    doc << (self.condition || "")
    doc << "# --\n"
    doc << (self.content || "(yas/unimplemented)")
    doc
  end

  def self.canonicalize(filename)
    invalid_char = /[^ a-z_0-9.+=~(){}\/'`&#,-]/i

    filename.
      gsub(invalid_char, '').  # remove invalid characters
      gsub(/ {2,}/,' ').       # squeeze repeated spaces into a single one
      rstrip                   # remove trailing whitespaces
  end

  def yas_file()
      File.join(TmSnippet::canonicalize(@file[0, @file.length-File.extname(@file).length]) + ".yasnippet")
  end

  def self.read_plist(xml_or_binary)
    begin
      parsed = Plist::parse_xml(xml_or_binary)
      return parsed if parsed
      raise ArgumentError.new "Probably in binary format and parse_xml is very quiet..."
    rescue StandardError => e
      if (system "plutil -convert xml1 #{xml_or_binary.shellescape} -o /tmp/textmate_import.tmpxml")
        return Plist::parse_xml("/tmp/textmate_import.tmpxml") 
      else
        raise RuntimeError.new "plutil failed miserably, check if you have it..."
      end
    end
  end

  private

  @@yas_to_tm_directives = {"condition" => "scope", "binding" => "keyEquivalent", "key" => "tabTrigger"}
  def yas_directive(yas_directive)
    #
    # Merge "known" hardcoded substitution with "extra" substitutions
    # provided in the .yas-setup.el file.
    # 
    merged = @@known_substitutions[yas_directive].
      merge(@@extra_substitutions[yas_directive])
    #
    # First look for an uuid-based direct substitution for this
    # directive.
    #
    if direct = merged[uuid]
      return "# #{yas_directive}: "+ direct + "\n" unless direct.empty?
    else
      tm_directive = @@yas_to_tm_directives[yas_directive]
      val = tm_directive && @snippet[tm_directive]
      if val and !val.delete(" ").empty? then
        #
        # Sort merged substitutions by length (bigger ones first,
        # regexps last), and apply them to the value gotten for plist.
        #
        merged.sort_by do |what, with|
          if what.respond_to? :length then -what.length else 0 end
        end.each do |sub|
          if val.gsub!(sub[0],sub[1])
            return "# #{yas_directive}: "+ val + "\n" unless val.empty?
          end
        end
        #
        # If we get here, no substitution matched, so mark this an
        # unknown substitution.
        #
        @@unknown_substitutions[yas_directive][val] = self
        return "## #{yas_directive}: \""+ val + "\n"
      end
    end
  end

end


if $0 == __FILE__
  # Read the the bundle's info.plist if can find it/guess it
  #
  info_plist_file = Choice.choices.info_plist || File.join(Choice.choices.bundle_dir,"info.plist")
  info_plist = TmSnippet::read_plist(info_plist_file) if info_plist_file and File.readable? info_plist_file;

  # Calculate the mode name
  # 
  modename = File.basename Choice.choices.output_dir || "major-mode-name"

  # Read in .yas-setup.el looking for the separator between auto-generated
  #
  original_dir = Dir.pwd
  yas_setup_el_file = File.join(original_dir, Choice.choices.output_dir, ".yas-setup.el")
  separator = ";; --**--"
  whole, head , tail = "", "", ""
  if File::exists? yas_setup_el_file
    File.open yas_setup_el_file, 'r' do |file|
      whole = file.read
      head , tail = whole.split(separator)
    end
  else
    head = ";; .yas-setup.el for #{modename}\n" + ";; \n"
  end

  # Now iterate the tail part to find extra substitutions
  #
  tail    ||= ""
  head    ||= ""
  directive = nil
  # puts "get this head #{head}"
  head.each_line do |line|
    case line
    when /^;; Substitutions for:(.*)$/
      directive = $~[1].strip
      # puts "found the directove #{directive}"
    when /^;;(.*)[ ]+=yyas>(.*)$/
      replacewith = $~[2].strip
      lookfor = $~[1]
      lookfor.gsub!(/^[ ]*/, "")
      lookfor.gsub!(/[ ]*$/, "")
      # puts "found this wonderful substitution for #{directive} which is #{lookfor} => #{replacewith}"
      unless !directive or replacewith =~ /yas\/unknown/ then 
        TmSnippet.extra_substitutions[directive][lookfor] = replacewith
      end
    end
  end

  # Glob snippets into snippet_files, going into subdirs
  #
  Dir.chdir Choice.choices.bundle_dir
  snippet_files_glob = File.join("**", Choice.choices.snippet)
  snippet_files = Dir.glob(snippet_files_glob)

  # Attempt to convert each snippet files in snippet_files
  #  
  puts "Will try to convert #{snippet_files.length} snippets...\n" unless Choice.choices.quiet
  

  # Iterate the globbed files
  #
  snippet_files.each do |file|
    begin
      puts "Processing \"#{File.join(Choice.choices.bundle_dir,file)}\"\n" unless Choice.choices.quiet
      snippet = TmSnippet.new(file,info_plist)

      if 
        file_to_create = File.join(original_dir, Choice.choices.output_dir, snippet.yas_file)
        FileUtils.mkdir_p(File.dirname(file_to_create))
        File.open(file_to_create, 'w') do |f|
          f.write(snippet.to_yas)
        end
      else
        if Choice.choices.print_pretty
          puts "--------------------------------------------"
        end
        puts snippet.to_yas if Choice.choices.print_pretty or not Choice.choices.info_plist
        if Choice.choices.print_pretty
          puts "--------------------------------------------\n\n"
        end
      end
    rescue SkipSnippet => e
      $stdout.puts "Skipping \"#{file}\": #{e.message}"
    rescue RuntimeError => e
      $stderr.puts "Oops.... \"#{file}\": #{e.message}"
      $strerr.puts "#{e.backtrace.join("\n")}" unless Choice.choices.quiet
    end
  end

  # Attempt to decypher the menu
  #
  menustr = TmSubmenu::main_menu_to_lisp(info_plist, modename) if info_plist
  puts menustr if $DEBUG

  # Write some basic .yas-* files
  #
  if Choice.choices.output_dir
    FileUtils.mkdir_p Choice.choices.output_dir
    FileUtils.touch File.join(original_dir, Choice.choices.output_dir, ".yas-make-groups") unless menustr
    FileUtils.touch File.join(original_dir, Choice.choices.output_dir, ".yas-ignore-filenames-as-triggers")
    
    # Now, output head + a new tail in (possibly new) .yas-setup.el
    # file
    #
    File.open yas_setup_el_file, 'w' do |file|
      file.puts head
      file.puts separator
      file.puts ";; Automatically generated code, do not edit this part"
      file.puts ";; "
      file.puts ";; Translated menu"
      file.puts ";; "
      file.puts menustr
      file.puts
      file.puts ";; Unknown substitutions"
      file.puts ";; "
      ["content", "condition", "binding"].each do |type|
        file.puts ";; Substitutions for: #{type}"
        file.puts ";; "
        # TmSnippet::extra_substitutions[type].
        #   each_pair do |k,v|
        #   file.puts ";; " + k + "" + (" " * [1, 90-k.length].max) + " =yyas> " + v
        # end
        unknown = TmSnippet::unknown_substitutions[type];
        unknown.keys.uniq.each do |k|
          file.puts ";; # as in " +  unknown[k].yas_file
          file.puts ";; " + k + "" + (" " * [1, 90-k.length].max) + " =yyas> (yas/unknown)"
          file.puts ";; "
        end
        file.puts ";; "
        file.puts
      end
      file.puts ";; .yas-setup.el for #{modename} ends here"
    end
  end
end
