Files
textmate/bin/rave

1580 lines
49 KiB
Ruby
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env ruby
require 'optparse'
require 'shellwords'
require 'fileutils'
require 'json'
require 'set'
VARIABLE_MATCH = /\$(?:\{(.+?)\}|(\w+))/
class Parser
attr_reader :globs, :targets, :environment
def initialize(ravefile)
@environment = {}
lines, ravefiles, globs = load_recursive(File.basename(ravefile))
records, more_globs = expand_globs(flatten(lines))
dirs = []
records.each do |variables, line|
command, args = line
case command
when :copy, :files
dirs += args[0...-1].select { |file| !file.start_with?('@') && File.directory?(file) }
end
end
@ravefiles = ravefiles
@globs = globs + more_globs + dirs.map { |dir| File.join(dir, '**') }
@targets = create_targets(records)
end
def dependencies
globs, files = @globs.partition { |path| path =~ /\*/ }
dirs = files.select { |file| File.directory?(file) }
res = Set.new(@ravefiles)
res.merge(watchlist([ *globs, *dirs.map { |dir| File.join(dir, '**') } ]))
@targets.each do |_, archs|
archs.each do |_, target|
if target[:captured_variables] && target[:inference]
target[:captured_variables].each do |variable|
res.merge(target[:inference][variable]) if target[:inference].include?(variable)
end
end
end
end
res, missing = res.partition { |file| File.exist?(file) }
missing.each { |file| STDERR << "*** reference is missing: #{file}\n" }
res
end
private
def load_recursive(ravefile)
res, globs, ravefiles = [], [], []
queue = [ { lines: res, file: ravefile } ]
while record = queue.pop
stack = []
lines, io = record[:lines], open(record[:file])
while line = io.gets
if line =~ /^\s*(?:(\})|(#.*)|([a-z_]+)\s*(.+?)\s*(\{)?)\s*$/
close_brace, comment, command, parameters, open_brace = $1, $2, $3, $4, $5
args = (parameters.nil? ? [] : Shellwords.split(parameters))
next if comment
if command == 'load'
args = args.map { |file| File.join(File.dirname(record[:file]), file) } if record[:file].include?('/')
globs += args
files = args.flat_map { |glob| Dir.glob(glob) }
ravefiles += files
files.each do |file|
queue << { lines: [], file: file }
lines << [ :load, [ file ], queue.last[:lines] ]
end
elsif open_brace
stack.push(lines)
lines << [ command.to_sym, args, [] ]
lines = lines.last.last
elsif close_brace
lines = stack.pop
else
lines << [ command.to_sym, args ]
end
end
end
end
[ res, ravefiles, globs ]
end
def flatten(root_lines)
res = []
queue = [ { lines: root_lines, variables: {} } ]
while record = queue.pop
lines, variables = record[:lines], record[:variables]
lines.each do |line|
command, args, options = *line
if options
args = args.map { |arg| expand_variables(arg, variables) }
temp = nil
case command
when :load
dir = File.dirname(args.first)
temp = dir == '.' ? variables : variables.merge(:dir => dir, 'dirname' => File.basename(dir))
when :target, :extend, :config, :arch
temp = variables.merge(command => args)
end
abort "Options not supported for #{command}" if temp.nil?
queue << { lines: options, variables: temp }
else
res << [ variables, line ]
end
end
end
res
end
def expand_globs(lines)
res, globs = [], []
lines.each do |variables, line|
command, args = line
inputs = nil
if [ :prelude, :sources, :headers, :tests, :cxx_tests ].include?(command)
inputs, args = args, []
elsif [ :files, :copy ].include?(command)
args, inputs = args.partition { |e| e.start_with?('@') }
args.push(inputs.pop)
end
if inputs
inputs = inputs.map { |input| File.join(variables[:dir], input) } if variables.include?(:dir)
expanded = inputs.flat_map { |input| Dir.glob(input) }
res << [ variables, [ command, expanded + args ] ]
globs += inputs
else
res << [ variables, line ]
end
end
[ res, globs ]
end
def create_targets(records)
n = 0
records = records.sort_by do |variables, line|
n += 1
[ variables[:extend] ? 2 : (variables[:target] ? 1 : 0), variables[:config] ? 1 : 0, variables[:arch] ? 1 : 0, n ]
end
targets = Hash.new { |hash, key| hash[key] = [] }
configs = Hash.new { |hash, key| hash[key] = [] }
archs = Hash.new { |hash, key| hash[key] = [] }
records.each do |record|
variables, _ = record
if variables.include?(:arch)
variables[:arch].each { |arch| archs[arch] << record }
elsif variables.include?(:config)
variables[:config].each { |config| configs[config] << record }
else
(variables[:target] || variables[:extend] || [ :no_target ]).each { |target| targets[target] << record }
end
end
config_to_targets_map = configs.map do |config_name, records|
[ config_name,
records.reduce(Set.new) do |set, record|
variables, _ = record
(variables[:target] || variables[:extend] || [ :no_target ]).each { |target_name| set << target_name }
set
end
]
end
res = []
targets.each do |target_name, records|
next if target_name.is_a?(Symbol)
config_names = config_to_targets_map.select { |config_name, set| set.include?(:no_target) || set.include?(target_name) }.map { |config_name, _| config_name }
config_names = [ :no_config ] if config_names.empty?
config_names.each do |config_name|
arch_names = archs.select do |arch_name, records|
records.find do |variables, _|
(variables[:target] || variables[:extend] || [ target_name ]).include?(target_name) && (variables[:config] || [ config_name ]).include?(config_name)
end
end.keys
arch_names = [ :no_arch ] if arch_names.empty?
res << [ (config_name.is_a?(Symbol) ? target_name : "#{target_name}/#{config_name}"),
arch_names.map do |arch_name|
target = []
target += targets[:no_target] if targets.include?(:no_target)
target += targets[target_name]
target += configs[config_name].select do |variables, _|
(variables[:target] || variables[:extend] || [ target_name ]).include?(target_name)
end
target += archs[arch_name].select do |variables, _|
(variables[:target] || variables[:extend] || [ target_name ]).include?(target_name) && (variables[:config] || [ config_name ]).include?(config_name)
end
lines = target.map do |variables, line|
unless config_name.is_a?(Symbol)
command, args = line
args = args.map { |arg| "#{arg}/#{config_name}" } if [ :require, :require_headers ].include?(command)
args = args.map { |arg| arg.sub(/^@.+$/, "\\&/#{config_name}") } if [ :files, :copy ].include?(command)
line = [ command, args ]
end
[ *line, { 'dir' => variables[:dir] || '.' } ]
end
env = { 'target' => target_name, 'config' => config_name, 'arch' => arch_name }.reject { |key, value| value.is_a?(Symbol) }
[ arch_name, { variables: env, lines: lines } ]
end
]
end
end
# ========================
# = Constrct the Targets =
# ========================
shell_cache = {}
res = res.map do |target_name, archs|
archs = archs.map do |arch_name, target|
variables = target[:variables]
n = 0
lines = target[:lines].sort_by do |command, _, _|
[ [ :set, :add, :capture, :expand ].include?(command) ? 0 : 1, n += 1 ]
end
lines.each do |command, args, env|
env = variables.merge(env)
case command
when :set, :add, :capture, :expand
value = args[1..-1].join(' ')
new_value = expand_variables(value, env)
files = []
if value.start_with?('${dir}/', '$dir/', '${HOME}/', '$HOME/')
files << new_value
elsif inference = target[:inference]
value.gsub(/\\([\\$])/, '').scan(VARIABLE_MATCH) do
files += inference[$1 || $2] if inference.include?($1 || $2)
end
end
if command == :capture
unless shell_cache.include?(new_value)
shell_cache[new_value] = %x{ #{new_value} }.chomp
end
variables[args.first] = shell_cache[new_value]
target[:captured_variables] ||= []
target[:captured_variables] << args.first
elsif command == :expand
infile = new_value
outfile = File.join("$builddir#{env.include?('config') ? "/#{env['config']}" : ""}", "_ExpandVariables", infile)
variables[args.first] = outfile
files = [ outfile ]
target[command] ||= []
target[command] << [ args.first, infile, outfile ]
elsif command == :add && env.include?(args.first)
variables[args.first] = env[args.first] + ' ' + new_value
else
variables[args.first] = new_value
end
unless files.empty?
target[:inference] ||= {}
target[:inference][args.first] ||= []
target[:inference][args.first] += files
end
when :prefix, :executable
target[command] = args.map { |value| expand_variables(value, env) }.join(' ')
target[:dir] = env['dir']
when :notarize
target[command] = args.map { |value| expand_variables(value, env) }
when :files, :copy
target[command] ||= {}
target[command][args.last] ||= []
target[command][args.last] += args[0...-1]
when :define
target[command] ||= []
target[command] << args
when :require, :require_headers, :prelude, :headers, :sources, :tests, :cxx_tests, :frameworks
target[command] ||= []
target[command] += args
when :libraries
target[command] ||= []
target[command] += args.map { |value| expand_variables(value, env) }
else
STDERR << "Unhandled command: #{command} #{args.join(' ')}\n"
end
end
target.delete(:lines)
[ arch_name, target ]
end
[ target_name, Hash[archs] ]
end
Hash[res]
end
# ======================
# = Variable Expansion =
# ======================
def expand_variables(value, variables)
value.gsub(/\\([\\$])|#{VARIABLE_MATCH}/) do
name = $2 || $3
if $1
$1
elsif variables.include?(name)
variables[name]
elsif ENV.include?(name)
@environment[name] ||= ENV[name]
else
STDERR << "*** unknown variable #{name} in line: #{value}\n"
STDERR << "Available: #{variables.keys.join(', ')}\n"
abort
end
end
end
# ========================================
# = Find dependencies from list of globs =
# ========================================
def expand_braces(string)
stack, children, escape = [], nil, false
siblings = [ { value: String.new } ]
string.split('').each do |ch|
if ch == '{' && !escape
stack.push([ siblings, children ])
siblings << { children: [ { siblings: [ { value: String.new } ] } ] }
children = siblings.last[:children]
siblings = children.last[:siblings]
elsif ch == ',' && children && !escape
children << { siblings: [ { value: String.new } ] }
siblings = children.last[:siblings]
elsif ch == '}' && children && !escape
siblings, children = stack.pop
siblings << { value: String.new }
else
escape = ch == '\\' && !escape
siblings.last[:value] << ch
end
end
siblings, _ = stack.pop until stack.empty?
unfold = lambda do |siblings|
res = [ '' ]
siblings.each do |sibling|
if value = sibling[:value]
res.each { |str| str << value }
elsif children = sibling[:children]
res = children.flat_map do |child|
unfold.call(child[:siblings]).flat_map do |value|
res.map { |str| str + value }
end
end
end
end
res
end
unfold.call(siblings)
end
def watchlist(globs)
paths = {}
globs.each do |glob|
expand_braces(glob).each do |file|
components = file.split(File::SEPARATOR).reject { |component| component == '.' }
components.each_with_index do |component, i|
if component.include?('**')
record = { watch_descendents: true }
elsif component.include?('*')
if i == components.size-1
record = { }
else
record = { watch_children: true }
end
else
next
end
path = components[0...i].join(File::SEPARATOR)
record = paths[path].merge(record) if paths[path]
paths[path] = record
break
end
end
end
res = Set.new
cache = {}
queue = paths.map do |path, options|
flag = if options && options[:watch_descendents]
:watch_descendents
elsif options && options[:watch_children]
:watch_children
end
[ path, flag ]
end
while record = queue.shift
path, flag = *record
res << path
if flag
if !cache[path]
cache[path] = Dir.entries(path).reject { |file| file.start_with?('.') }.map { |entry| File.join(path, entry) }.select { |file| File.directory?(file) }
end
cache[path].each do |file|
queue << [ file, flag == :watch_descendents ? flag : nil ]
end
end
end
res.to_a.sort
end
end
# ===================
# = Transformations =
# ===================
class Plugin
@@plugins = []
def self.inherited(subclass)
@@plugins << subclass
end
def self.plugins_of_type(klass)
@@plugins.select { |candidate| candidate < klass }
end
end
class Compiler < Plugin
class << self
def transforms(hash = nil)
@transforms = hash || @transforms
end
def extensions(hash = nil)
@extensions = hash || @extensions
end
def transform(file, config, builddir)
res = [ ]
exclude = Set.new
queue = [ file ]
while f = queue.shift
if compiler = find(f, exclude)
exclude << compiler.class
cache_key = "#{f}/#{(config['config'] || 'no_config')}"
if info = @@cache[cache_key]
res << info
queue << Array(info[:out]).first
if flags = info[:new_flags]
config = config.merge('FLAGS' => "#{config['FLAGS']} #{info[:new_flags]}")
end
else
info = compiler.transform(f, config, builddir)
if !compiler.respond_to?(:universal?) || !compiler.universal?
@@cache[cache_key] = info.merge(duplicate: true)
end
if info.include?(:new_flags)
config = config.merge('FLAGS' => "#{config['FLAGS']} #{info[:new_flags]}")
info.delete(:new_flags)
end
res << info
queue << Array(info[:out]).first
end
end
end
res.empty? ? nil : res
end
private
@@cache = {}
def find(file, exclude)
candidates = []
Plugin.plugins_of_type(Compiler).each do |klass|
klass.transforms.each do |ext, dest_ext|
if file =~ /\b#{Regexp.escape ext}$/
candidates << { class: klass, ext: ext, dest_ext: dest_ext }
end
end unless klass.transforms.nil?
klass.extensions.each do |canonical_ext, extensions|
extensions.each do |ext|
if file =~ /\b#{Regexp.escape ext}$/
candidates << { class: klass, ext: ext, dest_ext: klass.transforms[canonical_ext], canonical_ext: canonical_ext }
end
end
end unless klass.extensions.nil?
end
candidates.sort! { |lhs, rhs| lhs[:ext].length <=> rhs[:ext].length }
while match = candidates.pop
res = match[:class].new(match[:ext], match[:canonical_ext] || match[:ext], match[:dest_ext])
return res unless exclude.include?(match[:class])
end
end
end
def initialize(ext, canonical_ext, dest_ext)
@ext, @canonical_ext, @dest_ext = ext, canonical_ext, dest_ext
end
def filter?
@canonical_ext == @dest_ext
end
def transform(file, config, builddir)
sourcedir = File.dirname(file).sub(%r{#{Regexp.escape(builddir)}(/_\w+(?=/))?}, '')
arch = self.respond_to?(:universal?) && self.universal? && config.include?('arch') ? "-#{config['arch']}" : ""
{ in: file, out: File.join(builddir, "_#{self.class}#{arch}", sourcedir, File.basename(file).chomp(@ext) + @dest_ext), name: self.class.to_s }
end
end
class CompileClang < Compiler
transforms '.c' => '.o', '.m' => '.o', '.cc' => '.o', '.mm' => '.o'
extensions '.cc' => [ '.c++', '.cxx', '.cpp' ]
COMPILER_INFO = {
'.c' => { :flags => 'C_FLAGS', :type => 'c-header' },
'.m' => { :flags => 'OBJC_FLAGS', :type => 'objective-c-header' },
'.cc' => { :flags => 'CXX_FLAGS', :type => 'c++-header' },
'.mm' => { :flags => 'OBJCXX_FLAGS', :type => 'objective-c++-header' },
}
@@prelude = Set.new
def universal?
true
end
def transform(file, config, builddir)
info = COMPILER_INFO[@canonical_ext]
flags = []
flags << config['FLAGS'] if config.include?('FLAGS')
flags << config[info[:flags]] if config.include?(info[:flags])
pch, dependencies = nil, []
# Precompiled header
if prelude = config[:prelude]
if header = prelude.find { |file| File.extname(file) == @canonical_ext }
arch = config.include?('arch') ? "-#{config['arch']}" : ""
output = File.join(builddir, "_PCH#{arch}", File.dirname(header), File.basename(header) + '.gch')
unless @@prelude.include?(output)
@@prelude << output
pch = {
name: 'PCH',
in: header,
out: output,
flags: [ '-x', info[:type], *flags ].join(' '),
command: 'xcrun --sdk macosx clang -c -o $out $flags -MMD -MF $out.d $in',
depfile: '$out.d',
deps: 'gcc',
description: 'Precompile ${in}’…',
}
end
dependencies << output
flags << [ "-include", output.chomp('.gch').shellescape ]
end
end
flags << config[:include].map { |dir| "-I#{dir.shellescape}" } if config.include?(:include)
super.merge(
before: pch,
flags: flags.join(' '),
command: 'xcrun --sdk macosx clang -c -o $out $flags -MMD -MF $out.d $in',
depfile: '$out.d',
deps: 'gcc',
description: 'Compile ${in}’…',
dependencies: dependencies,
order_only: config[:include],
)
end
end
class CompileRagel < Compiler
transforms '.rl' => '.cc', '.mm.rl' => '.mm', '.cc.rl' => '.cc'
def transform(file, config, builddir)
super.merge(
flags: config['RAGEL_FLAGS'],
command: 'ragel -o $out $flags $in',
description: 'Ragel ${in}’…',
new_flags: "-iquote #{File.dirname(file).shellescape}",
dependencies: config.dig(:inference, 'RAGEL_FLAGS'),
)
end
end
class CompileCapnp < Compiler
transforms '.capnp' => '.capnp.c++'
def transform(file, config, builddir)
base = super
source = base[:out]
header = source.chomp('.c++') + '.h'
base.merge(
out: [ source, header ],
flags: "-oc++:#{File.dirname(source).shellescape} --src-prefix=#{File.dirname(file).shellescape}",
command: 'capnp compile $flags $in',
description: 'Capn Proto ${in}’…',
)
end
end
class CompileXib < Compiler
transforms '.xib' => '.nib'
def transform(file, config, builddir)
super.merge(
flags: config['IB_FLAGS'],
command: 'xcrun ibtool --compile $out $flags $in',
description: 'Xib ${in}’…',
dependencies: config.dig(:inference, 'IB_FLAGS'),
)
end
end
class CompileAssetCatalog < Compiler
transforms '.xcassets' => '.car'
def transform(file, config, builddir)
super.merge(
out: File.join(builddir, "_#{self.class}", File.dirname(file), 'Assets.car'),
flags: config['AC_FLAGS'],
command: 'xcrun actool --compile "$(dirname $out)" $flags $in',
description: 'Assets ${in}’…',
dependencies: Dir.glob("#{file}/**/*") + (config.dig(:inference, 'AC_FLAGS') || []),
)
end
end
class ConvertToUTF16 < Compiler
transforms '.strings' => '.strings'
def transform(file, config, builddir)
super.merge(
command: 'if [[ "$(head -c2 $in)" == $\'\\xFF\\xFE\' || "$(head -c2 $in)" == $\'\\xFE\\xFF\' ]]; then /bin/cp -Xp $in $out && touch $out; else iconv -f utf-8 -t utf-16 < $in > $out~ && mv $out~ $out; fi',
description: 'Encode ${in} as UTF-16…',
)
end
end
class ExpandVariables < Compiler
transforms 'Info.plist' => 'Info.plist', 'InfoPlist.strings' => 'InfoPlist.strings'
def transform(file, config, builddir)
flags = [ "-dYEAR=#{Time.now.year}" ]
flags << config['PLIST_FLAGS'] if config.include?('PLIST_FLAGS')
super.merge(
flags: flags.join(' '),
command: "#{__dir__}/expand_variables -o $out $flags $in",
description: 'Expand variables ${in}’…',
dependencies: [ "#{__dir__}/expand_variables", *(config.dig(:inference, 'PLIST_FLAGS') || []) ],
)
end
end
class CompileMarkdown < Compiler
transforms '.md' => '.html'
extensions '.md' => [ '.mdown' ]
def transform(file, config, builddir)
super.merge(
flags: config['MD_FLAGS'],
command: "#{__dir__}/gen_html > $out~ $flags $in && mv $out~ $out",
description: 'Markdown ${in}’…',
dependencies: [ "#{__dir__}/gen_html", *(config.dig(:inference, 'MD_FLAGS') || []) ],
)
end
end
class CreateBundlesArchive < Compiler
transforms '.tbz.bl' => '.tbz'
def transform(file, config, builddir)
bundles = {
name: "DownloadBundles",
in: file,
out: File.join(builddir, "_#{self.class}", File.dirname(file), 'Managed'),
command: "$bl -C $out install $(<$in) && touch $out",
description: 'Download bundles…',
pool: 'console',
bl: "#{builddir}/Applications/bl/bl",
order_only: "#{builddir}/Applications/bl/bl",
}
flags = [ '--disable-copyfile' ]
if pbzip2 = ENV['PATH'].split(':').map { |path| File.join(path, 'pbzip2') }.find { |path| File.exist?(path) }
flags << "--use-compress-prog=#{pbzip2.shellescape}"
else
flags << '-j'
end
super.merge(
before: [ bundles ],
in: bundles[:out],
flags: flags.join(' '),
command: '/usr/bin/tar $flags -cf $out~ -C $(dirname $in) $(basename $in) && mv $out~ $out',
description: 'Archive ${in}’…',
)
end
end
# ==========
# = Target =
# ==========
module Target
module_function
def prepare(target)
unless target.include?(:build)
target[:build] = { rules: [] }
target[:name] = target[:variables]['target']
target[:identifier] = target[:variables]['target']
target[:arch] = target[:variables]['arch'] || :no_arch
target[:builddir] = '$builddir'
if config = target[:variables]['config']
target[:identifier] += '/' + config
target[:builddir] += '/' + config
end
end
target
end
def required_targets(target, targets, include_self: false, include_weak: false)
res, seen = [], Set.new
queue = []
queue << target[:identifier] if include_self
queue += target[:require] if target.include?(:require)
queue += target[:require_headers] if include_weak && target.include?(:require_headers)
while name = queue.shift
next if seen.include?(name)
seen.add(name)
if archs = targets[name]
res << archs
if required_target = archs[target[:arch]]
queue += required_target[:require] if required_target.include?(:require)
queue += required_target[:require_headers] if include_weak && required_target.include?(:require_headers)
else
STDERR << "*** No #{target[:arch]} architecture for #{name} (required by #{target[:identifier]})\n"
end
else
STDERR << "*** No target named #{name} (required by #{target[:identifier]})\n"
end
end
res
end
def include_dir(archs)
target = archs.values.first
unless target[:build][:include_dir]
include_dir, rules = nil, []
if headers = target[:headers]
include_dir = File.join(target[:builddir], '_Include', target[:name])
headers = headers.map do |header|
rules << {
name: 'ExportHeader',
in: header,
out: File.join(include_dir, target[:name], File.basename(header)),
command: '/bin/cp -Xp $in $out && touch $out',
description: 'Export ${in}’…',
}
rules.last[:out]
end
rules << {
name: 'phony',
in: headers,
out: include_dir
}
end
target[:build][:rules] += rules
target[:build][:include_dir] = include_dir
end
target[:build][:include_dir]
end
def objects(target, targets)
unless target[:build][:objects]
objects, rules = [], []
if sources = target[:sources]
include_dirs = required_targets(target, targets, include_weak: true).map { |archs| include_dir(archs) }.reject { |e| e.nil? }.sort
config = target[:variables].merge(prelude: target[:prelude], inference: target[:inference], include: include_dirs.sort.uniq)
# ===============
# = Capn Proto =
# ===============
generated_headers = []
sources, non_sources = sources.partition { |file| file.end_with?('.c', '.m', '.cc', '.mm') }
unless non_sources.empty?
non_sources.each do |file|
if links = Compiler.transform(file, config, target[:builddir])
generated_headers += links.map { |link| link[:out] }.flatten.select { |file| file.end_with?('.h', '.hpp') }
objects << links.last[:out]
link = links.shift
rules += [ link.merge(order_only: [ *Array(link[:order_only]) ]), *links ]
end
end
flags = generated_headers.map { |path| File.dirname(path) }.sort.uniq.map { |dir| "-iquote #{dir.shellescape}" }
flags << config['FLAGS'] if config.include?('FLAGS')
config['FLAGS'] = flags.join(' ')
end
# ===============
sources.each do |file|
if links = Compiler.transform(file, config, target[:builddir])
objects << links.last[:out]
link = links.shift
rules += [ link.merge(order_only: [ *generated_headers, *Array(link[:order_only]) ]), *links ]
end
end
end
target[:build][:rules] += rules
target[:build][:objects] = objects
end
target[:build][:objects]
end
def executable(target, targets)
unless target[:build][:executable]
executable, rules = nil, []
if executable = target[:executable]
required_targets = required_targets(target, targets, include_self: true).map { |archs| archs[target[:arch]] }
objects = required_targets.flat_map do |target|
objects(target, targets)
end
frameworks = required_targets.flat_map { |target| target[:frameworks] }.reject { |e| e.nil? }.sort.uniq
libraries = required_targets.flat_map { |target| target[:libraries] }.reject { |e| e.nil? }.sort.uniq
static, libraries = libraries.partition { |lib| lib =~ /\.a$/ }
deps = []
ln_flags = []
required_targets.each do |target|
ln_flags += target[:variables]['LN_FLAGS'].split(' ') if target[:variables].include?('LN_FLAGS')
deps += target.dig(:inference, 'LN_FLAGS') || []
end
ln_flags = [ ln_flags.uniq.join(' ') ]
deps = deps.uniq.sort
ln_flags += frameworks.map { |e| "-framework #{e.shellescape}" }
ln_flags += libraries.map { |e| "-l#{e.shellescape}" }
ln_flags += static
executable = if target[:arch].is_a?(String)
File.join(target[:builddir], "_Link-#{target[:arch]}", target[:name])
else
File.join(*[ target[:builddir], target[:dir], target[:prefix], target[:executable] ].reject { |e| e.nil? })
end
rules << {
name: 'Link',
in: objects.sort.uniq,
out: executable,
flags: ln_flags.join(' '),
command: 'xcrun --sdk macosx clang -o $out $flags $in',
description: 'Link ${out}’…',
dependencies: deps
}
end
target[:build][:rules] += rules
target[:build][:executable] = executable
end
target[:build][:executable]
end
def lipo(archs, targets)
target = archs.values.first
unless target[:build][:lipo]
rules, lipo = [], nil
executables = archs.map { |_, target| executable(target, targets) }.reject { |e| e.nil? }
if executables.size > 1
lipo = File.join(*[ target[:builddir], target[:dir], target[:prefix], target[:executable] ].reject { |e| e.nil? })
rules << {
name: 'Lipo',
in: executables,
out: lipo,
command: 'xcrun --sdk macosx lipo $in -create -output $out',
description: 'Lipo ${out}’…',
}
else
lipo = executables.first
end
target[:build][:rules] += rules
target[:build][:lipo] = lipo
end
target[:build][:lipo]
end
def assets(archs, targets)
target = archs.values.first
unless target[:build][:assets]
assets, rules = [], []
config = target[:variables].merge(inference: target[:inference])
[ :files, :copy ].each do |key|
if hash = target[key]
queue = hash.to_a
while pair = queue.pop
dest, files = pair
files.each do |file|
if file =~ /^@(.+)$/
if archs = targets[$1]
assets += assets_with_executable(archs, targets).map do |src, dst, deps|
[ src, File.join(dest, dst), deps ]
end
else
STDERR << "*** Unknown target #$1 referenced by #{target[:target]}\n"
end
else
if key == :files
if links = Compiler.transform(file, config, target[:builddir])
rules += links
file = links.last[:out]
end
end
if File.directory?(file)
queue << [
File.join(dest, File.basename(file)),
Dir.entries(file).reject { |path| path =~ /^\./ }.map { |entry| File.join(file, entry) }
]
else
assets << [ file, File.join(dest, File.basename(file)).gsub(%r{(^|\G|(?<=/))\./|/\.$}, ''), [] ]
end
end
end
end
end
end
target[:build][:rules] += rules
target[:build][:assets] = assets
end
target[:build][:assets]
end
def assets_with_executable(archs, targets)
target = archs.values.first
unless target[:build][:bundle]
bundle, rules = [], []
executable = lipo(archs, targets)
signature = signature(archs, targets)
if prefix = target[:prefix]
required_targets(target, targets, include_self: true).each do |other|
assets(other, targets).each do |src, dst, deps|
bundle << [ src, File.join(prefix, dst), [ signature, *deps ] ]
end
end
if executable
bundle << [ executable, File.join(prefix, target[:executable]), [ signature ] ]
bundle << [ signature, File.join(prefix, '_CodeSignature/CodeResources'), [] ]
end
elsif executable
bundle << [ executable, target[:executable], [ signature ] ]
end
target[:build][:rules] += rules
target[:build][:bundle] = bundle
end
target[:build][:bundle]
end
def application(archs, targets)
target = archs.values.first
unless target[:build][:application]
application, rules = nil, []
if prefix = target[:prefix]
if executable = Target.lipo(archs, targets)
application = executable.chomp(File.join(prefix, target[:executable]).sub(%r{^[^/]+}, ''))
rules << {
name: 'phony',
out: application,
}
end
end
target[:build][:rules] += rules
target[:build][:application] = application
end
target[:build][:application]
end
def signature(archs, targets)
target = archs.values.first
unless target[:build][:signature]
signature, rules = nil, []
if executable = Target.lipo(archs, targets)
signature = File.join(target[:builddir], '_Sign', "#{target[:name]}.ok")
dependencies = []
if application = application(archs, targets)
dependencies << executable
executable = application
signature = File.join(application, 'Contents/_CodeSignature/CodeResources')
required_targets(target, targets, include_self: true).each do |other|
assets(other, targets).each do |src, dst, deps|
rules << {
name: 'CopyFile',
in: src,
out: File.join(target[:builddir], target[:dir], target[:prefix], dst),
command: '/bin/cp -Xp $in $out && touch $out',
description: 'Copy ${in}’…',
dependencies: deps,
}
dependencies << rules.last[:out]
end
end
end
rules << {
name: 'Codesign',
in: executable,
out: signature,
identity: target[:variables]['CS_IDENTITY'] || '-',
flags: target[:variables]['CS_FLAGS'],
command: 'xcrun codesign --sign "$identity" $flags $in && touch $out',
description: 'Sign ${in}’…',
dependencies: dependencies + (target.dig(:inference, 'CS_FLAGS') || []),
}
rules << {
name: 'phony',
in: signature,
out: target[:identifier],
}
end
target[:build][:rules] += rules
target[:build][:signature] = signature
end
target[:build][:signature]
end
def runner(archs, targets)
target = archs.values.first
unless target[:build][:runner]
runner, rules = nil, []
if executable = Target.lipo(archs, targets)
runner = File.join(target[:builddir], '.always-run-' + target[:name])
if application = application(archs, targets)
rules << {
name: 'RunApplication',
in: application,
out: runner,
command: <<~SHELL.gsub(/\s+/, ' '),
{
app_name=$(basename $in .app);
if pgrep "$$app_name"; then
if [[ -x "$$DIALOG" && $("$$DIALOG" alert --title "Relaunch $$app_name?" --body "Would you like to quit $$app_name and start the newly built version?" --button1 Relaunch --button2 Cancel|pl) != *"buttonClicked = 0"* ]];
then exit;
fi;
pkill "$$app_name";
while pgrep "$$app_name"; do
if (( ++n == 10 )); then
test -x "$$DIALOG" && "$$DIALOG" alert --title "Relaunch Timed Out" --body "Unable to exit $$app_name." --button1 OK;
exit;
fi;
sleep .2;
done;
fi;
while ! pgrep "$$app_name"; do
if (( ++m == 10 )); then
test -x "$$DIALOG" && "$$DIALOG" alert --title "Relaunch Timed Out" --body "Unable to launch $$app_name." --button1 OK;
exit;
fi;
open $in --args -disableSessionRestore NO;
sleep .2;
done;
} </dev/null &>/dev/null &
SHELL
dependencies: Target.signature(archs, targets),
description: 'Run $in…',
}
else
rules << {
name: 'RunExecutable',
in: executable,
out: runner,
command: '$in',
description: 'Run $in…',
dependencies: Target.signature(archs, targets),
pool: 'console',
}
end
rules << {
name: 'phony',
in: runner,
out: target[:identifier] + '/run',
}
end
target[:build][:rules] += rules
target[:build][:runner] = runner
end
target[:build][:runner]
end
def notarize(archs, targets)
target = archs.values.first
unless target[:build][:notarize]
notarize, rules = nil, []
if args = target[:notarize]
application = application(archs, targets)
executable = Target.lipo(archs, targets)
if application || executable
guardfile = File.join(target[:builddir], '_Notarize', target[:name])
guardfile << '_' + target[:variables]['version'] if target[:variables].include?('version')
notarize = guardfile + '.ok'
rules << {
name: 'Zip',
in: application || executable,
out: "#{guardfile}.zip",
command: '/usr/bin/ditto -ck --keepParent --norsrc --noextattr --noacl --noqtn $in $out~ && mv $out~ $out',
description: 'Zip ${in}’…',
dependencies: signature(archs, targets),
}
rules << { user: args[0], pass: args[1], bundle: args[2] }.merge(
name: 'NotarizeSubmit',
in: rules.last[:out],
out: "#{guardfile}-submit.xml",
command: 'xcrun altool --notarize-app > $out~ -u "$user" -p "$pass" --output-format xml -f $in --primary-bundle-id "$bundle" -t osx && mv $out~ $out',
description: 'Submit ${in} for notarization…',
)
rules << { user: args[0], pass: args[1] }.merge(
name: 'NotarizeStatus',
in: rules.last[:out],
out: "#{guardfile}-status.xml",
command: "#{__dir__}/notarize_await \"xcrun altool --notarization-info '$(plutil -extract notarization-upload.RequestUUID xml1 -o - $in|sed -n 's/.*<string>\\(.*\\)<\\/string>.*/\\1/p')' -u '$user' -p '$pass' --output-format xml\" > $out~ && mv $out~ $out",
description: 'Waiting for notarization…',
dependencies: "#{__dir__}/notarize_await",
pool: 'console',
)
if application
rules << {
name: 'NotarizeStaple',
in: application,
out: notarize,
command: 'xcrun stapler staple $in && touch $out',
description: 'Staple ${in}’…',
dependencies: rules.last[:out],
}
else
rules << {
name: 'NotarizeConfirm',
in: rules.last[:out],
out: notarize,
command: 'touch $out',
description: 'Touch ${out}’…',
}
end
rules << {
name: 'phony',
in: notarize,
out: target[:identifier] + '/notarize',
}
end
end
target[:build][:rules] += rules
target[:build][:notarize] = notarize
end
target[:build][:notarize]
end
def defines(archs, targets)
target = archs.values.first
unless target[:build][:defines]
defines, rules = true, []
if defines = target[:define]
variables = target[:variables].merge(
'executable' => lipo(archs, targets),
'application' => application(archs, targets),
)
inference = (target[:inference] || {}).merge(
'executable' => notarize(archs, targets) || signature(archs, targets),
'application' => notarize(archs, targets) || signature(archs, targets),
)
guardfile = File.join(target[:builddir], '_Actions', target[:name])
guardfile << '_' + target[:variables]['version'] if target[:variables].include?('version')
defines.each do |name, command|
variables[name] = "#{guardfile}.#{name}"
inference[name] = "#{guardfile}.#{name}"
end
defines.each do |name, command|
variable_names = command.scan(VARIABLE_MATCH).map { |a, b| a || b }.uniq
dep_files = variable_names.flat_map { |name| inference[name] }
dep_files = dep_files.reject { |e| e.nil? }.sort.uniq
temp = variable_names.map { |name| [ name, variables[name] || ENV[name] ] }.reject { |key, value| value.nil? }
rules << temp.to_h.merge(
name: target[:identifier].tr('/', '_') + '_' + name.capitalize,
keep: true,
out: variables[name],
command: "#{command =~ /\|(?!\|)(?<!\|\|)/ ? 'set -o pipefail; ' : ''}#{command} > $out~ && mv $out~ $out",
dependencies: dep_files,
description: "#{name.capitalize} #{File.basename(guardfile)}’…",
pool: 'console',
)
rules << {
name: 'phony',
in: rules.last[:out],
out: target[:identifier] + '/' + name,
}
end
end
target[:build][:rules] += rules
target[:build][:defines] = defines
end
target[:build][:defines]
end
def expand(archs, targets)
target = archs.values.first
unless target[:build][:expand]
expand, rules = true, []
if array = target[:expand]
array.each do |variable, infile, outfile|
rules << {
name: 'ExpandVariables',
in: infile,
out: outfile,
flags: target[:variables]['PLIST_FLAGS'],
command: "#{__dir__}/expand_variables -o $out $flags $in",
description: 'Expand variables ${in}’…',
dependencies: [ "#{__dir__}/expand_variables", *(target.dig(:inference, 'PLIST_FLAGS') || []) ],
}
end
end
target[:build][:rules] += rules
target[:build][:expand] = expand
end
target[:build][:expand]
end
end
# ====================
# = Output Buildfile =
# ====================
NINJA_KEYS = %w[ command description depfile deps generator pool ]
def write_rules(io, rules)
actions = Set.new
flat_rules = []
queue = rules.dup
while rule = queue.shift
if before = rule[:before]
rule.delete(:before)
queue = [ *(before.is_a?(Array) ? before : [ before ]), rule, *queue ]
else
flat_rules << rule
end
end
rules = flat_rules.sort_by { |rule| rule[:name].downcase }
rules.each do |rule|
next if actions.include?(rule[:name]) || rule[:name] == 'phony'
actions.add(rule[:name])
io << "rule #{rule[:name]}\n"
rule.each do |key, value|
next unless NINJA_KEYS.include?(key.to_s)
io << " #{key} = #{value.gsub(/\$(?![a-zA-Z{$])/, '$$')}\n"
end
io << "\n"
end
escape = lambda do |x|
Array(x).map { |file| file.gsub(/(?!\$\{?builddir\}?)[ :$]/, '$\&') }.join(' ')
end
rules = rules.sort_by { |rule| Array(rule[:out]).first.downcase }
rules.each do |rule|
io << 'build '
if rule[:out].kind_of?(Array)
io << escape.call(rule[:out].first)
io << ' | ' << escape.call(rule[:out][1..-1]) unless rule[:out][1..-1].empty?
else
io << escape.call(rule[:out])
end
io << ': ' << escape.call(rule[:name])
io << ' ' << escape.call(rule[:in]) unless rule[:in].nil? || rule[:in].empty?
io << ' | ' << escape.call(rule[:dependencies]) unless rule[:dependencies].nil? || rule[:dependencies].empty?
io << ' || ' << escape.call(rule[:order_only]) unless rule[:order_only].nil? || rule[:order_only].empty?
io << "\n"
rule.each do |key, value|
next if value.nil?
next if NINJA_KEYS.include?(key.to_s)
next if %w[ name in out dependencies order_only keep ].include?(key.to_s)
io << " #{key} = #{value}\n"
end
io << "\n"
end
end
# ===========
# = Program =
# ===========
if __FILE__ == $PROGRAM_NAME
flags = ARGV.dup
builddir = File.expand_path("~/build/#{File.basename(Dir.pwd)}")
outfile = 'build.ninja'
depfile = '$builddir/build.ninja.d'
default_config = nil
default_target = nil
OptionParser.new do |opts|
opts.banner = "Usage: #{File.basename(__FILE__)} [options] [ravefile]"
opts.separator "Synopsis"
opts.separator "#{File.basename(__FILE__)}: create build.ninja from rave file"
opts.separator "Options:"
opts.on("-h", "--help", "Show help.") do
puts opts
exit
end
opts.on("-b", "--builddir DIRECTORY", "Defaults to #{builddir}.") do |dir|
builddir = dir
end
opts.on("-o", "--output FILE", "Defaults to #{outfile}.") do |file|
outfile = file
end
opts.on("-d", "--depfile FILE", "Defaults to #{depfile}.") do |file|
depfile = file
end
opts.on("-c", "--config CONFIG", "Set default config, e.g. debug.") do |arg|
default_config = arg
end
opts.on("-t", "--target TARGET", "set default target, e.g. MyApp/run.") do |arg|
default_target = arg
end
end.parse!
ravefile = ARGV.shift || 'default.rave'
ravefile = '/dev/stdin' if ravefile == '-'
parser = Parser.new(ravefile)
targets = parser.targets
targets.each { |_, archs| archs.each { |_, target| Target.prepare(target) } }
targets.each do |target_name, archs|
Target.expand(archs, targets)
Target.notarize(archs, targets)
Target.runner(archs, targets)
Target.signature(archs, targets)
Target.defines(archs, targets)
end
rules = targets.flat_map do |_, archs|
archs.flat_map do |_, target|
target[:build][:rules].reject { |rule| rule[:duplicate] }
end
end
if default_config
phonies = rules.select { |rule| rule[:name] == 'phony' }.map { |rule| Array(rule[:out]).first }
phonies.select! { |name| name =~ %r{^[^/$]+/#{Regexp.escape(default_config)}(/|$)} }
phonies.each do |name|
rules << {
name: 'phony',
in: name,
out: name.sub(%r{/#{Regexp.escape(default_config)}(?=/|$)}, ''),
}
end
end
open(outfile == '-' ? '/dev/stdout' : outfile, 'w') do |io|
io << "builddir = #{builddir}\n"
io << "\n"
io << "rule MakeBuildfile\n"
io << " command = ${env}#{__FILE__} $flags\n"
io << " depfile = #{depfile}\n"
io << " deps = gcc\n"
io << " generator = true\n"
io << " description = Generate $out\n"
io << "\n"
io << "build #{outfile}: MakeBuildfile #{ravefile} | #{__FILE__}\n"
io << " flags = #{flags.shelljoin}\n"
io << " env = #{parser.environment.map { |key, value| "#{key}=#{value.shellescape} " }.join}\n"
io << "\n"
write_rules(io, rules)
io << "default #{default_target}\n\n" if default_target
io << "include local.ninja\n\n" if File.exist?('local.ninja')
end
depfile = depfile.gsub(/\$builddir|\$\{builddir\}/, builddir)
FileUtils.mkdir_p(File.dirname(depfile))
open(depfile, 'w') do |io|
io << "#{outfile}: "
io << parser.dependencies.to_a.sort.map { |dep| dep.gsub(/ /, '\\ ') }.join(" \\\n ")
io << "\n"
end
# ======================
# = Remove old outputs =
# ======================
outputs_file = File.expand_path('outputs.json', builddir)
new_outputs = Set.new(rules.reject { |rule| rule[:name] == 'phony' || rule[:keep] }.flat_map { |rule| Array(rule[:out]).map { |file| file.sub(/^\$\{?builddir\}?(?=\/)/, builddir) } })
new_outputs << outputs_file
old_outputs = nil
begin
old_outputs = Set.new(JSON.parse(File.read(outputs_file)))
rescue Errno::ENOENT
rescue Exception => e
STDERR << "Error reading #{outputs_file}: #{e}\n"
end
if new_outputs && old_outputs
lost_outputs = old_outputs - new_outputs
lost_outputs.each do |path|
if File.exists?(path)
STDERR << "Remove old target #{path}’…\n"
# FIXME Does not seem to work with symbolic links (Proxy.png → Settings.png)
File.unlink(path)
end
rescue Exception => e
STDERR << "File.unlink: #{e}\n"
end
end
if json = JSON.generate(new_outputs.to_a)
FileUtils.mkdir_p(File.dirname(outputs_file))
File.write(outputs_file, json)
end
end