468 lines
14 KiB
Ruby
468 lines
14 KiB
Ruby
|
#!/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 'trollop'
|
||
|
require 'fileutils'
|
||
|
require 'shellwords' # String#shellescape
|
||
|
require 'ruby-debug' if $DEBUG
|
||
|
|
||
|
|
||
|
opts = Trollop::options do
|
||
|
opt :bundle_dir, "TextMate bundle directory", :short => '-d', :type => :string
|
||
|
opt :output_dir, "Output directory", :short => '-o', :type => :string
|
||
|
opt :glob, "Specific snippet file (or glob) inside <bundle_dir>", :short => '-g', :default => '*.{tmSnippet,tmCommand,plist,tmMacro}'
|
||
|
opt :pretty, 'Pretty prints multiple snippets when printing to standard out', :short => '-p'
|
||
|
opt :quiet, "Be quiet", :short => '-q'
|
||
|
opt :plist_file, "Use a specific plist file to derive menu information from", :type => :string
|
||
|
end
|
||
|
Trollop::die :bundle_dir, "must be provided" unless opts.bundle_dir
|
||
|
Trollop::die :bundle_dir, "must exist" unless File.directory? opts.bundle_dir
|
||
|
|
||
|
Trollop::die :output_dir, "must be provided" unless opts.output_dir
|
||
|
Trollop::die :output_dir, "must exist" unless File.directory? opts.output_dir
|
||
|
|
||
|
Trollop::die :plist_file, "must exist" if opts.plist_file && File.directory?(opts.plist_file)
|
||
|
|
||
|
|
||
|
# 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 && 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.
|
||
|
#
|
||
|
allsubs = merged.sort_by do |what, with|
|
||
|
if what.respond_to? :length then -what.length else 0 end
|
||
|
end
|
||
|
allsubs.each do |sub|
|
||
|
if val.gsub!(sub[0],sub[1])
|
||
|
# puts "SUBBED #{sub[0]} for #{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 __FILE__ == $PROGRAM_NAME
|
||
|
# Read the the bundle's info.plist if can find it/guess it
|
||
|
#
|
||
|
info_plist_file = opts.plist_file || File.join(opts.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 opts.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, opts.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 opts.bundle_dir
|
||
|
snippet_files_glob = File.join("**", opts.glob)
|
||
|
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 opts.quiet
|
||
|
|
||
|
|
||
|
# Iterate the globbed files
|
||
|
#
|
||
|
snippet_files.each do |file|
|
||
|
begin
|
||
|
$stdout.print "Processing \"#{File.join(opts.bundle_dir,file)}\"..." unless opts.quiet
|
||
|
snippet = TmSnippet.new(file,info_plist)
|
||
|
|
||
|
file_to_create = File.join(original_dir, opts.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
|
||
|
$stdout.print "done\n" unless opts.quiet
|
||
|
rescue SkipSnippet => e
|
||
|
$stdout.print "skipped! #{e.message}\n" unless opts.quiet
|
||
|
rescue RuntimeError => e
|
||
|
$stderr.print "failed! #{e.message}\n"
|
||
|
$strerr.print "#{e.backtrace.join("\n")}" unless opts.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 opts.output_dir
|
||
|
FileUtils.mkdir_p opts.output_dir
|
||
|
FileUtils.touch File.join(original_dir, opts.output_dir, ".yas-make-groups") unless menustr
|
||
|
|
||
|
# 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
|