1#!/usr/bin/env ruby -Ku
2# vim:ts=2:sw=2:expandtab:
3# Copyright (c) 2006-2007, The RubyCocoa Project.
4# Copyright (c) 2007 Chris Mcgrath. (from nb_nibtool.rb)
5# Copyright (c) 2007 cho45. (rubycocoa command)
6# All Rights Reserved.
7#
8# RubyCocoa is free software, covered under either the Ruby's license or the 
9# LGPL. See the COPYRIGHT file for more information.
10#
11#
12# This command does NOT require rubygems/rake
13# but the generated templates may include Rakefile and gem dependency.
14
15require "optparse"
16require "pathname"
17require 'fileutils'
18require "osx/cocoa"
19require "osx/xcode"
20require "osx/standaloneify"
21require "nkf"
22require "iconv"
23require "erb"
24include FileUtils
25include OSX
26
27class RubyCocoaCommand
28  VERSION = "$Revision: 1846 $"
29  CONFIG_DIR = Pathname.new("#{ENV["HOME"]}/.rubycocoa")
30  ORGANIZATIONNAME = (ENV['ORGANIZATIONNAME'].nil? ? '«ORGANIZATIONNAME»' : ENV['ORGANIZATIONNAME'])
31
32  def self.run(argv)
33    new(argv.dup).run
34  end
35
36  def initialize(argv)
37    @argv = argv
38
39    @subparsers = {
40      "help" => OptionParser.new { |opts|
41        opts.banner = <<-EOB.gsub(/^ {10}/, "")
42          Usage: rubycocoa help <subcommand>
43
44          Show help of subcommand.
45        EOB
46      },
47
48      "new" => OptionParser.new {|opts|
49        opts.banner = <<-EOB.gsub(/^ {10}/, "")
50          Usage: rubycocoa new [options] "Application Name"
51
52          Create new application skelton.
53        EOB
54
55        opts.separator ""
56
57        opts.separator "Options:"
58        opts.on("--template TEMPLATE", "specify templates (template dir.)") {|@template|}
59      },
60
61      "create" =>  OptionParser.new {|opts|
62        opts.banner = <<-EOB.gsub(/^ {10}/, "")
63          Usage: rubycocoa create [options] ClassName[<SuperClass=NSObject]
64
65          Create a ruby class skelton.
66        EOB
67
68        opts.separator ""
69
70        @actions = []
71        @outlets = []
72        opts.separator "Options:"
73        opts.on("-a NAME", "--action NAME", "With action") {|action| @actions << action}
74        opts.on("-o NAME", "--outlet NAME", "With outlet") {|outlet| @outlets << outlet}
75      },
76
77      "update" =>  OptionParser.new {|opts|
78        opts.banner = <<-EOB.gsub(/^ {10}/, "")
79          Usage: rubycocoa update [options] <Nib File> <Ruby File>
80
81          Update nib with ruby class difinition.
82        EOB
83        opts.separator ""
84
85        opts.separator "Options:"
86        opts.on("-a", "--add_class", "Add class if the class is not in nib.") {|@add_class|}
87      },
88
89      "convert" =>  OptionParser.new {|opts|
90        opts.banner = <<-EOB.gsub(/^ {10}/, "")
91          Usage: rubycocoa convert [options] <Nib File | Obj-c Header File>
92
93          Generate files including ruby class from a nib or header.
94        EOB
95
96        opts.separator ""
97
98        opts.separator "Options:"
99        opts.on("--overwrite", "overwite all files") {|@overwrite|}
100      },
101
102      "add" =>  OptionParser.new {|opts|
103        opts.banner = <<-EOB.gsub(/^ {10}/, "")
104          Usage: rubycocoa add [options] <Add Files> <Xcode Project>
105
106          Add files to Xcode project and resource build phase.
107        EOB
108        opts.separator ""
109
110        opts.separator "Options:"
111        opts.on("-t", "--type", "Specify file type (eg. text.script.ruby)") {|@type|}
112        opts.on("-p", "--pathtype", "Specify path type (<group>)") {|@pathtype|}
113        opts.on("-g", "--group", "Specify group (Classes)") {|@group|}
114        opts.on("--only-add", "Only add files (not adding to build phase)") {|@only_add|}
115      },
116
117      "standaloneify" =>  OptionParser.new {|opts|
118        @extra_libs = []
119        opts.banner = <<-EOB.gsub(/^ {10}/, "")
120          Usage: rubycocoa standaloneify [options] <Ruby Cocoa.app> <Dest.app>
121
122          Creates a new application that should have dependencies resolved.
123        EOB
124        opts.separator ""
125
126        opts.separator "Options:"
127        opts.on("-f","--force","Delete target app if it exists already") { |@force| }
128        opts.on("-l LIBRARY","--lib","Extra library to bundle") { |lib| @extra_libs << lib }
129      },
130    }
131
132    @parser = OptionParser.new do |parser|
133      parser.banner  = <<-EOB.gsub(/^ {8}/, "")
134        Usage: rubycocoa <subcommand> [options] <files>
135
136        The 'rubycocoa' command is a part of RubyCocoa.
137        It's a tool for creating new application skeltons.
138
139        Intro:
140                You can create a RubyCocoa application skelton with the 'new' subcommand:
141
142                    $ rubycocoa new "New RubyCocoa Application"
143
144                After selecting the template, 'rubycocoa' will create the application directory in the current directory.
145      EOB
146
147      parser.separator ""
148
149      parser.separator "Subcommands:"
150      @subparsers.keys.sort.each do |k|
151        parser.separator "#{parser.summary_indent}    #{k}"
152      end
153
154      parser.separator ""
155
156      parser.separator "Options:"
157      parser.on('--version', "Show version string `#{VERSION}'") do
158        puts VERSION
159        exit
160      end
161    end
162  end
163
164  def run
165    @parser.order!(@argv)
166    if @argv.empty?
167      puts @parser.help
168      exit
169    else
170      @subcommand = @argv.shift
171      method_name = "cmd_#{@subcommand}"
172      if self.respond_to?(method_name)
173        @subparsers[@subcommand].parse!(@argv)
174        self.send(method_name)
175      else
176        puts "Not implemented subcommand: `#{@subcommand}'."
177        puts
178        puts @parser.help
179      end
180    end
181  end
182
183  def cmd_new
184    abort @subparsers[@subcommand].help if @argv.empty?
185
186    appname, = @argv
187    abort "Application Name is required" if appname.nil? || appname.empty?
188
189    if @template
190      template = Pathname.new @template
191      abort "#{template} is not exists." unless template.exist?
192    else
193      templates = Pathname.glob(CONFIG_DIR + "templates/*")
194      templates.concat Pathname.glob("/Library/Application Support/Apple/Developer Tools/Project Templates/Application/Cocoa-Ruby*")
195      templates.each_with_index do |f,i|
196        puts "% 2d: %s %s" % [i, f.basename, i == 0 ? "(default)" : ""]
197      end
198      print "Select Template> "
199      num = $stdin.gets.chomp
200      num = "0" if num.empty?
201      abort "Canceled because the input was not a number" unless num =~ /^\d/
202
203      num = num.to_i
204      template = templates[num]
205    end
206    puts "Creating `#{appname}' using `#{template}'..."
207
208    dest = Pathname.new(appname)
209    abort "#{appname} already exists. Exiting." if dest.exist?
210    cp_r template, dest
211    Pathname.glob(dest + "*Cocoa*App*") do |f|
212      f.rename(f.parent + f.basename.to_s.sub(/Cocoa(?:Doc)?App/, appname))
213    end
214    plist = read_plist(dest + "#{appname}.xcodeproj/TemplateInfo.plist")
215    (dest + "#{appname}.xcodeproj/TemplateInfo.plist").unlink
216
217    (plist["FilesToRename"] || {}).each do |k, v|
218      (dest + k).rename(apply_template(v, :project_name => appname))
219      puts "#{k} => #{v}"
220    end
221    ((plist["FilesToMacroExpand"].to_a || []) << "#{appname}.xcodeproj/project.pbxproj").each do |name|
222      f = dest + apply_template(name, :project_name => appname)
223      #next unless f.exist?
224      puts "Expanding Macro: #{f}"
225      f.open("rb+") do |g|
226        str = g.read
227        str = Iconv.conv("ISO-8859-1", "UTF-16", str) if f.basename.to_s == 'InfoPlist.strings'
228        if ['InfoPlist.strings', 'rb_main.rb', 'main.m'].include?(f.basename.to_s)
229          str = str.gsub(/\307|\253/, '��').gsub(/\310|\273/, '��')
230        end
231        content = apply_template(str, :project_name => appname, :file => f)
232        content = Iconv.conv("UTF-16", "ISO-8859-1", content) if f.basename.to_s == 'InfoPlist.strings'
233        g.rewind
234        g << content
235        g.truncate g.tell
236      end
237    end
238  end
239
240  def cmd_convert
241    abort @subparsers[@subcommand].help if @argv.empty?
242
243    file = Pathname.new @argv[0]
244    tmpl = class_skelton
245
246    if file.extname == ".nib"
247      plist_path = file + "classes.nib"
248      plist = read_plist(plist_path)
249      plist["IBClasses"].each do |l|
250        class_name  = l["CLASS"].to_s
251        next if class_name == "FirstResponder"
252        super_class = l["SUPERCLASS"].to_s
253        actions = l["ACTIONS"] ? l["ACTIONS"].allKeys.map {|i| i.to_s } : []
254        outlets = l["OUTLETS"] ? l["OUTLETS"].allKeys.map {|i| i.to_s } : []
255        result =  ERB.new(tmpl, $SAFE, '-').result(binding)
256
257        path = Pathname.new(class_name + ".rb")
258        if path.exist?
259          puts "#{path} exists. skip."
260        else
261          puts "-> #{path}"
262          path.open("w") do |f|
263            f.puts result
264          end
265        end
266      end
267    else
268      header = file.read
269      _, class_name, super_class = */@interface ([^\s]+) : ([^\s]+)/.match(header)
270
271      outlets = header.scan(/IBOutlet id (.*?);/)
272      actions = header.scan(/^- \(IBAction\)(.*?):\(id\)sender;/)
273      result =  ERB.new(tmpl, $SAFE, '-').result(binding)
274
275      path = Pathname.new(class_name + ".rb")
276      if path.exist?
277        puts "#{path} exists. skip."
278      else
279        puts "-> #{path}"
280        path.open("w") do |f|
281          f.puts result
282        end
283      end
284    end
285  end
286
287  def cmd_create
288    abort @subparsers[@subcommand].help if @argv.empty?
289
290    _, class_name, super_class = */^([^<]+)(?:<(.+))?$/.match(@argv[0])
291    tmpl = class_skelton
292
293    super_class = "NSObject" unless super_class
294
295    outlets = @outlets
296    actions = @actions
297    result  = ERB.new(tmpl, $SAFE, '-').result(binding)
298
299    path = Pathname.new(class_name + ".rb")
300    if path.exist?
301      puts "#{path} exists. skip."
302    else
303      puts "-> #{path}"
304      path.open("w") do |f|
305        f.puts result
306      end
307    end
308  end
309
310  def cmd_update
311    abort @subparsers[@subcommand].help if @argv.empty?
312
313    nib, rb, = *@argv
314    nib = Pathname.pwd + nib
315    rb  = Pathname.pwd + rb
316    puts "Update `#{nib.basename}' with `#{rb.basename}'"
317
318    class << NSObject
319      @@collect_child_classes = false
320      @@subklasses = {}
321      @@current_class = nil
322
323      def ib_outlets(*args)
324        args.each do |arg|
325          puts "found outlet #{arg} in #{@@current_class}"
326          ((@@subklasses[@@current_class] ||= {})[:outlets] ||= []) << arg
327        end
328      end
329
330      alias_method :ns_outlet,  :ib_outlets
331      alias_method :ib_outlet,  :ib_outlets
332      alias_method :ns_outlets, :ib_outlets
333
334      def ib_action(name, &blk)
335        puts "found action #{name} in #{@@current_class}"
336        ((@@subklasses[@@current_class] ||= {})[:actions] ||= []) << name
337      end
338
339      alias_method :_before_classes_nib_inherited, :inherited
340      def inherited(subklass)
341        if @@collect_child_classes
342          unless subklass.to_s == ""
343            puts "current class: #{subklass.to_s}"
344            @@current_class = subklass.to_s
345          end
346        end
347        _before_classes_nib_inherited(subklass)
348      end
349    end
350    NSObject.instance_eval { @@collect_child_classes = true }
351    load rb
352    NSObject.instance_eval { @@collect_child_classes = false }
353
354    plist_path = nib + "classes.nib"
355    plist = read_plist(plist_path)
356
357    NSObject.instance_eval { @@subklasses }.each do |k, v|
358      class_def = plist["IBClasses"].find {|i| i["CLASS"] == k }
359      unless class_def
360        if @add_class
361          class_def = NSMutableDictionary.alloc.init
362          class_def['CLASS'] = k
363          class_def['LANGUAGE'] = 'ObjC'
364          plist['IBClasses'].addObject(class_def)
365        else
366          puts "Ruby class `#{k}' is not in nib."
367          next
368        end
369      end
370
371      klass = k.split("::").inject(Object) { |par, const| par.const_get(const) }
372      superklass = klass.superclass.to_s.sub(/^OSX::/, '')
373      class_def.setObject_forKey(superklass, "SUPERCLASS")
374
375      %w(outlets actions).each do |t|
376        next unless v[t.to_sym]
377        updated = NSMutableDictionary.dictionary
378        v[t.to_sym].each do |item|
379          puts "adding #{t} #{item}"
380          updated.setObject_forKey('id', item)
381        end
382        class_def[t.upcase] = updated unless updated.count == 0
383      end
384    end
385
386    plist_path.open("wb") {|f| f.puts plist }
387  end
388
389  def cmd_help
390    subcommand, = @argv
391    if subcommand
392      if @subparsers.key? subcommand
393        puts @subparsers[subcommand].help
394      else
395        puts "No such subcommand `#{subcommand}'"
396        puts @parser.help
397      end
398    else
399      puts @parser.help
400    end
401  end
402
403  def cmd_package
404    exec("rake package")
405  end
406
407  def cmd_add
408    abort @subparsers[@subcommand].help if @argv.empty?
409
410    proj_path = @argv.pop
411    files = @argv
412
413    @group = "Classes" unless @group
414    @pathtype = "<group>" unless @pathtype
415
416    proj = XcodeProject.new(proj_path)
417    files.each do |f|
418      f = Pathname.new(f)
419      if f.directory?
420        puts "#{f} is directory. skip."
421      end
422      type = "text.script.ruby" unless @type
423      puts "Adding `#{f}' as `#{type}' to `#{@group} in `#{proj_path}'."
424      if proj.objects.find {|k,v|  v["isa"] == "PBXFileReference" and v["path"] == f }
425        puts "`#{f}' is already in the Xcode project"
426      else
427        id = proj.groups[@group].add_file(type, f, @pathtype)
428        unless @only_add
429          puts "Adding `#{f}' to resource build phase."
430          proj.add_file_to_resouce_phase(id)
431        end
432      end
433    end
434    proj.save
435  end
436
437  def cmd_standaloneify
438    src, dest, = *@argv
439    if @force
440      rm_rf dest
441    end
442    Standaloneify.make_standalone_application(src, dest, @extra_libs)
443  end
444
445  def read_plist(path)
446      plist = NSPropertyListSerialization.objc_send(
447        :propertyListFromData, NSData.alloc.initWithContentsOfFile(path.to_s),
448        :mutabilityOption, NSPropertyListMutableContainersAndLeaves,
449        :format, nil,
450        :errorDescription, nil
451      )
452      unless plist
453        abort "Error while reading `#{path}'"
454      end
455      plist
456  end
457
458  def apply_template(str, opts)
459    str.to_s.gsub(/��([A-Z]+)��/) do
460      m = Regexp.last_match
461      case m[1]
462      when "DATE"
463        now = NSCalendarDate.calendarDate
464        now.setCalendarFormat("%x")
465        now.description
466      when "TIME"
467        now = NSCalendarDate.calendarDate
468        now.setCalendarFormat("%X")
469        now.description
470      when "YEAR"
471        Time.now.year
472      when "DIRECTORY"
473        opts[:file].parent.basename
474      when "FILEEXTENSION"
475        opts[:file].extname
476      when "FILENAME"
477        opts[:file].basename
478      when "FILEBASENAME"
479        opts[:file].basename(".*")
480      when "FILEBASENAMEASIDENTIFIER"
481        opts[:file].basename(".*").gsub(/\s+/, "_")
482      when "FULLUSERNAME"
483        OSX.NSFullUserName.to_s
484      when "USERNAME"
485        OSX.NSUserName.to_s
486      when "PROJECTNAME"
487        opts[:project_name]
488      when "PROJECTNAMEASIDENTIFIER"
489        opts[:project_name].gsub(/\s+/, "_")
490      when "PROJECTNAMEASXML"
491        opts[:project_name].gsub(/&/, "&amp;").gsub(/>/, "&gt;").gsub(/</, "&lt;")
492      when "ORGANIZATIONNAME"
493        ORGANIZATIONNAME
494      else
495        m[0]
496      end
497    end
498  end
499
500  def class_skelton
501    unless @class_skelton
502      @class_skelton = <<-EOS.gsub(/        /, "")
503        require 'osx/cocoa'
504        include OSX
505
506        class <%=class_name%> < <%=super_class%>
507          <%- outlets.each do |outlet| -%>
508          ib_outlets :<%=outlet%>
509          <%- end %>
510          <%- actions.each do |action| -%>
511
512          ib_action :<%=action%> do |sender|
513          end
514          <%- end %>
515
516          def awakeFromNib
517          end
518        end
519      EOS
520    end
521    users = CONFIG_DIR + "class.rb"
522    if users.exist?
523      users.read
524    else
525      @class_skelton
526    end
527  end
528end
529
530if $0 == __FILE__
531  RubyCocoaCommand.run(ARGV)
532end
533
534
535