# Copyright (C) 2017 Vladimir Makarov, <vmakarov@redhat.com>
# This is a script to transform functions to static inline.
# Usage: transform_mjit_header.rb <c-compiler> <header file> <out>

require 'fileutils'
require 'tempfile'

PROGRAM = File.basename($0, ".*")

module MJITHeader
  ATTR_VALUE_REGEXP  = /[^()]|\([^()]*\)/
  ATTR_REGEXP        = /__attribute__\s*\(\(#{ATTR_VALUE_REGEXP}*\)\)/
  # Example:
  #   VALUE foo(int bar)
  #   VALUE __attribute__ ((foo)) bar(int baz)
  #   __attribute__ ((foo)) VALUE bar(int baz)
  FUNC_HEADER_REGEXP = /\A[^\[{(]*(\s*#{ATTR_REGEXP})*[^\[{(]*\((#{ATTR_REGEXP}|[^()])*\)(\s*#{ATTR_REGEXP})*\s*/
  TARGET_NAME_REGEXP = /\A(rb|ruby|vm|insn|attr|Init)_/

  # Predefined macros for compilers which are already supported by MJIT.
  # We're going to support cl.exe too (WIP) but `cl.exe -E` can't produce macro.
  SUPPORTED_CC_MACROS = [
    '__GNUC__', # gcc
    '__clang__', # clang
  ]

  # These macros are relied on this script's transformation
  PREFIXED_MACROS = [
    'ALWAYS_INLINE',
    'COLDFUNC',
    'inline',
    'RBIMPL_ATTR_COLD',
  ]

  # For MinGW's ras.h. Those macros have its name in its definition and can't be preprocessed multiple times.
  RECURSIVE_MACROS = %w[
    RASCTRYINFO
    RASIPADDR
  ]

  IGNORED_FUNCTIONS = [
    'rb_vm_search_method_slowpath', # This increases the time to compile when inlined. So we use it as external function.
    'rb_equal_opt', # Not used from VM and not compilable
  ]

  ALWAYS_INLINED_FUNCTIONS = [
    'vm_opt_plus',
    'vm_opt_minus',
    'vm_opt_mult',
    'vm_opt_div',
    'vm_opt_mod',
    'vm_opt_neq',
    'vm_opt_lt',
    'vm_opt_le',
    'vm_opt_gt',
    'vm_opt_ge',
    'vm_opt_ltlt',
    'vm_opt_and',
    'vm_opt_or',
    'vm_opt_aref',
    'vm_opt_aset',
    'vm_opt_aref_with',
    'vm_opt_aset_with',
    'vm_opt_not',
  ]

  COLD_FUNCTIONS = %w[
    setup_parameters_complex
    vm_call_iseq_setup
    vm_call_iseq_setup_2
    vm_call_iseq_setup_tailcall
    vm_call_method_each_type
    vm_ic_update
  ]

  # Return start..stop of last decl in CODE ending STOP
  def self.find_decl(code, stop)
    level = 0
    i = stop
    while i = code.rindex(/[;{}]/, i)
      if level == 0 && stop != i && decl_found?($&, i)
        return decl_start($&, i)..stop
      end
      case $&
      when '}'
        level += 1
      when '{'
        level -= 1
      end
      i -= 1
    end
    nil
  end

  def self.decl_found?(code, i)
    i == 0 || code == ';' || code == '}'
  end

  def self.decl_start(code, i)
    if i == 0 && code != ';' && code != '}'
      0
    else
      i + 1
    end
  end

  # Given DECL return the name of it, nil if failed
  def self.decl_name_of(decl)
    ident_regex = /\w+/
    decl = decl.gsub(/^#.+$/, '') # remove macros
    reduced_decl = decl.gsub(ATTR_REGEXP, '') # remove attributes
    su1_regex = /{[^{}]*}/
    su2_regex = /{([^{}]|#{su1_regex})*}/
    su3_regex = /{([^{}]|#{su2_regex})*}/ # 3 nested structs/unions is probably enough
    reduced_decl.gsub!(su3_regex, '') # remove structs/unions in the header
    id_seq_regex = /\s*(?:#{ident_regex}(?:\s+|\s*[*]+\s*))*/
    # Process function header:
    match = /\A#{id_seq_regex}(?<name>#{ident_regex})\s*\(/.match(reduced_decl)
    return match[:name] if match
    # Process non-function declaration:
    reduced_decl.gsub!(/\s*=[^;]+(?=;)/, '') # remove initialization
    match = /#{id_seq_regex}(?<name>#{ident_regex})/.match(reduced_decl);
    return match[:name] if match
    nil
  end

  # Return true if CC with CFLAGS compiles successfully the current code.
  # Use STAGE in the message in case of a compilation failure
  def self.check_code!(code, cc, cflags, stage)
    with_code(code) do |path|
      cmd = "#{cc} #{cflags} #{path}"
      out = IO.popen(cmd, err: [:child, :out], &:read)
      unless $?.success?
        STDERR.puts "error in #{stage} header file:\n#{out}"
        exit false
      end
    end
  end

  # Remove unpreprocessable macros
  def self.remove_harmful_macros!(code)
    code.gsub!(/^#define #{Regexp.union(RECURSIVE_MACROS)} .*$/, '')
  end

  # -dD outputs those macros, and it produces redefinition warnings or errors
  # This assumes common.mk passes `-DMJIT_HEADER` first when it creates rb_mjit_header.h.
  def self.remove_predefined_macros!(code)
    code.sub!(/\A(#define [^\n]+|\n)*(#define MJIT_HEADER 1\n)/, '\2')
  end

  # Return [macro, others]. But others include PREFIXED_MACROS to be used in code.
  def self.separate_macro_and_code(code)
    code.lines.partition do |l|
      l.start_with?('#') && PREFIXED_MACROS.all? { |m| !l.start_with?("#define #{m}") }
    end.map! { |lines| lines.join('') }
  end

  def self.write(code, out)
    # create with strict permission, then will install proper
    # permission
    FileUtils.mkdir_p(File.dirname(out), mode: 0700)
    File.binwrite("#{out}.new", code, perm: 0600)
    FileUtils.mv("#{out}.new", out)
  end

  # Note that this checks runruby. This conservatively covers platform names.
  def self.windows?
    RUBY_PLATFORM =~ /mswin|mingw|msys/
  end

  def self.cl_exe?(cc)
    cc =~ /\Acl(\z| |\.exe)/
  end

  # If code has macro which only supported compilers predefine, return true.
  def self.supported_header?(code)
    SUPPORTED_CC_MACROS.any? { |macro| code =~ /^#\s*define\s+#{Regexp.escape(macro)}\b/ }
  end

  # This checks if syntax check outputs one of the following messages.
  #    "error: conflicting types for 'restrict'"
  #    "error: redefinition of parameter 'restrict'"
  # If it's true, this script regards platform as AIX or Solaris and adds -std=c99 as workaround.
  def self.conflicting_types?(code, cc, cflags)
    with_code(code) do |path|
      cmd = "#{cc} #{cflags} #{path}"
      out = IO.popen(cmd, err: [:child, :out], &:read)
      !$?.success? &&
        (out.match?(/error: conflicting types for '[^']+'/) ||
         out.match?(/error: redefinition of parameter '[^']+'/))
    end
  end

  def self.with_code(code)
    # for `system_header` pragma which can't be in the main file.
    Tempfile.open(['', '.h'], mode: File::BINARY) do |f|
      f.puts code
      f.close
      Tempfile.open(['', '.c'], mode: File::BINARY) do |c|
        c.puts <<SRC
#include "#{f.path}"
SRC
        c.close
        return yield(c.path)
      end
    end
  end
  private_class_method :with_code
end

if ARGV.size != 3
  abort "Usage: #{$0} <c-compiler> <header file> <out>"
end

if STDOUT.tty?
  require_relative 'lib/colorize'
  color = Colorize.new
end
cc      = ARGV[0]
code    = File.binread(ARGV[1]) # Current version of the header file.
outfile = ARGV[2]
if MJITHeader.cl_exe?(cc)
  cflags = '-DMJIT_HEADER -Zs'
else
  cflags = '-S -DMJIT_HEADER -fsyntax-only -Werror=implicit-function-declaration -Werror=implicit-int -Wfatal-errors'
end

if !MJITHeader.cl_exe?(cc) && !MJITHeader.supported_header?(code)
  puts "This compiler (#{cc}) looks not supported for MJIT. Giving up to generate MJIT header."
  MJITHeader.write("#error MJIT does not support '#{cc}' yet", outfile)
  exit
end

MJITHeader.remove_predefined_macros!(code)

if MJITHeader.windows? # transformation is broken with Windows headers for now
  MJITHeader.remove_harmful_macros!(code)
  MJITHeader.check_code!(code, cc, cflags, 'initial')
  puts "\nSkipped transforming external functions to static on Windows."
  MJITHeader.write(code, outfile)
  exit
end

macro, code = MJITHeader.separate_macro_and_code(code) # note: this does not work on MinGW
code = <<header + code
#ifdef __GNUC__
# pragma GCC system_header
#endif
header
code_to_check = "#{code}#{macro}" # macro should not affect code again

if MJITHeader.conflicting_types?(code_to_check, cc, cflags)
  cflags = "#{cflags} -std=c99" # For AIX gcc
end

# Check initial file correctness in the manner of final output.
MJITHeader.check_code!(code_to_check, cc, cflags, 'initial')

stop_pos       = -1
extern_names   = []
transform_logs = Hash.new { |h, k| h[k] = [] }

# This loop changes function declarations to static inline.
while (decl_range = MJITHeader.find_decl(code, stop_pos))
  stop_pos = decl_range.begin - 1
  decl = code[decl_range]
  decl_name = MJITHeader.decl_name_of(decl)

  if MJITHeader::IGNORED_FUNCTIONS.include?(decl_name) && /#{MJITHeader::FUNC_HEADER_REGEXP}{/.match(decl)
    transform_logs[:def_to_decl] << decl_name
    code[decl_range] = decl.sub(/{.+}/m, ';')
  elsif MJITHeader::COLD_FUNCTIONS.include?(decl_name) && match = /#{MJITHeader::FUNC_HEADER_REGEXP}{/.match(decl)
    header = match[0].sub(/{\z/, '').strip
    header = "static #{header.sub(/\A((static|inline) )+/, '')}"
    decl[match.begin(0)...match.end(0)] = '{' # remove header
    code[decl_range] = "\nCOLDFUNC #{header} #{decl}"
  elsif MJITHeader::ALWAYS_INLINED_FUNCTIONS.include?(decl_name) && match = /#{MJITHeader::FUNC_HEADER_REGEXP}{/.match(decl)
    header = match[0].sub(/{\z/, '').strip
    header = "static inline #{header.sub(/\A((static|inline) )+/, '')}"
    decl[match.begin(0)...match.end(0)] = '{' # remove header
    code[decl_range] = "\nALWAYS_INLINE(#{header});\n#{header} #{decl}"
  elsif extern_names.include?(decl_name) && (decl =~ /#{MJITHeader::FUNC_HEADER_REGEXP};/)
    decl.sub!(/(extern|static|inline) /, ' ')
    unless decl_name =~ /\Aattr_\w+_\w+\z/ # skip too-many false-positive warnings in insns_info.inc.
      transform_logs[:static_inline_decl] << decl_name
    end

    code[decl_range] = "static inline #{decl}"
  elsif (match = /#{MJITHeader::FUNC_HEADER_REGEXP}{/.match(decl)) && (header = match[0]) !~ /static/
    unless decl_name.match(MJITHeader::TARGET_NAME_REGEXP)
      transform_logs[:skipped] << decl_name
      next
    end

    extern_names << decl_name
    decl[match.begin(0)...match.end(0)] = ''

    if decl =~ /\bstatic\b/
      abort "#{PROGRAM}: a static decl was found inside external definition #{decl_name.dump}"
    end

    header.sub!(/(extern|inline) /, ' ')
    unless decl_name =~ /\Aattr_\w+_\w+\z/ # skip too-many false-positive warnings in insns_info.inc.
      transform_logs[:static_inline_def] << decl_name
    end
    code[decl_range] = "static inline #{header}#{decl}"
  end
end

code << macro

# Check the final file correctness
MJITHeader.check_code!(code, cc, cflags, 'final')

MJITHeader.write(code, outfile)

messages = {
  def_to_decl: 'changing definition to declaration',
  static_inline_def: 'making external definition static inline',
  static_inline_decl: 'making declaration static inline',
  skipped: 'SKIPPED to transform',
}
transform_logs.each do |key, decl_names|
  decl_names = decl_names.map { |s| color.bold(s) } if color
  puts("#{PROGRAM}: #{messages.fetch(key)}: #{decl_names.join(', ')}")
end
