require 'rubygems' if RUBY_VERSION < '1.9'
require 'logger'
require 'yaml'
require 'rake'
require 'rake/tasklib'
require 'rake/path'
require 'rake/file_task_alias'
require 'compiler'

module Rake

  class Formatter < Logger::Formatter
    def call(severity, time, progname, msg)
      msg2str(msg) << "\n"
    end
  end

  # Error indicating that the project failed to build.
  class BuildFailureError < StandardError
  end

  class Builder < TaskLib

    module VERSION #:nodoc:
      MAJOR = 0
      MINOR = 0
      TINY  = 13

      STRING = [ MAJOR, MINOR, TINY ].join('.')
    end

    # The file to be built
    attr_accessor :target

    # The type of file to be built
    # One of: :executable, :static_library, :shared_library
    # If not set, this is deduced from the target.
    attr_accessor :target_type

    # The types of file that can be built
    TARGET_TYPES = [ :executable, :static_library, :shared_library ]

    # processor type: 'i386', 'x86_64', 'ppc' or 'ppc64'.
    attr_accessor :architecture

    # The programming language: 'c++', 'c' or 'objective-c' (default 'c++')
    # This also sets defaults for source_file_extension
    attr_accessor :programming_language

    # Programmaing languages that Rake::Builder can handle
    KNOWN_LANGUAGES = {
      'c' => {
        :source_file_extension => 'c',
        :compiler              => 'gcc',
        :linker                => 'gcc'
      },
      'c++' => {
        :source_file_extension => 'cpp',
        :compiler              => 'g++',
        :linker                => 'g++'
      },
      'objective-c' => {
        :source_file_extension => 'm',
        :compiler              => 'gcc',
        :linker                => 'gcc'
      },
    }

    # The compiler that will be used
    attr_accessor :compiler

    # The linker that will be used
    attr_accessor :linker

    # Extension of source files (default 'cpp' for C++ and 'c' for C)
    attr_accessor :source_file_extension

    # Extension of header files (default 'h')
    attr_accessor :header_file_extension

    # The path of the Rakefile
    # All paths are relative to this
    attr_reader   :rakefile_path

    # The Rakefile
    # The file is not necessarily called 'Rakefile'
    # It is the file which calls to Rake::Builder.new
    attr_reader   :rakefile

    # Directories/file globs to search for project source files
    attr_accessor :source_search_paths

    # Directories/file globs to search for header files
    # When static libraries are installed,
    # headers are installed too.
    # During installation, the destination path is:
    #   /usr/local/include + the relative path
    # This 'relative path' is calculated as follows:
    # 1. Any files named in header_search_paths are installed directly under /usr/local/include
    # 2. The contents of any directory named in header_search_paths are also installed directly under /usr/local/include
    # 3. Files found by glob have the fixed part of the glob removed and
    #  the relative path calculated:
    # E.g. files found with './include/**/*' will have './include' removed to calculate the
    #  relative path.
    # So, ./include/my_lib/foo.h' produces a relative path of 'my_lib'
    # so the file will be installed as '/usr/local/include/my_lib/foo.h'
    attr_accessor :header_search_paths

    # (Optional) namespace for tasks
    attr_accessor :task_namespace

    # Name of the default task
    attr_accessor :default_task

    # Tasks which the target file depends upon
    attr_accessor :target_prerequisites

    # Directory to be used for object files
    attr_accessor :objects_path

    # Array of extra options to pass to the compiler
    attr_accessor :compilation_options

    # Additional include directories for compilation
    attr_accessor :include_paths

    # Additional library directories for linking
    attr_accessor :library_paths

    # extra options to pass to the linker
    attr_accessor :linker_options

    # Libraries to be linked
    attr_accessor :library_dependencies

    # The directory where 'rake install' will copy the target file
    attr_accessor :install_path

    # Name of the generated file containing source - header dependencies
    attr_reader   :makedepend_file

    # Temporary files generated during compilation and linking
    attr_accessor :generated_files

    # Each instance has its own logger
    attr_accessor :logger

    def initialize( &block )
      save_rakefile_info( block )
      initialize_attributes
      block.call( self )
      configure
      define_tasks
      define_default
    end

    private

    # Source files found in source_search_paths
    def source_files
      @source_files ||= find_files( @source_search_paths, @source_file_extension ).uniq
      @source_files
    end

    # Header files found in header_search_paths
    def header_files
      @header_files ||= find_files( @header_search_paths, @header_file_extension ).uniq
      @header_files
    end

    def initialize_attributes
      @architecture          = 'i386'
      @compiler_data         = Compiler::Base.for( :gcc )
      @logger                = Logger.new( STDOUT )
      @logger.level          = Logger::UNKNOWN
      @logger.formatter      = Formatter.new
      @programming_language  = 'c++'
      @header_file_extension = 'h'
      @objects_path          = @rakefile_path.dup
      @library_paths         = []
      @library_dependencies  = []
      @target_prerequisites  = []
      @source_search_paths   = [ @rakefile_path.dup ]
      @header_search_paths   = [ @rakefile_path.dup ]
      @target                = 'a.out'
      @generated_files       = []
      @compilation_options   = []
      @include_paths         = []
    end

    def configure
      @compilation_options   += [ architecture_option ] if RUBY_PLATFORM =~ /apple/i
      @compilation_options.uniq!

      @programming_language.downcase!
      raise "Don't know how to build '#{ @programming_language }' programs" if KNOWN_LANGUAGES[ @programming_language ].nil?
      @compiler              ||= KNOWN_LANGUAGES[ @programming_language ][ :compiler ]
      @linker                ||= KNOWN_LANGUAGES[ @programming_language ][ :linker ]
      @source_file_extension ||= KNOWN_LANGUAGES[ @programming_language ][ :source_file_extension ]

      @source_search_paths   = Rake::Path.expand_all_with_root( @source_search_paths, @rakefile_path )
      @header_search_paths   = Rake::Path.expand_all_with_root( @header_search_paths, @rakefile_path )
      @library_paths         = Rake::Path.expand_all_with_root( @library_paths, @rakefile_path )

      raise "The target name cannot be nil" if @target.nil?
      raise "The target name cannot be an empty string" if @target == ''
      @objects_path          = Rake::Path.expand_with_root( @objects_path, @rakefile_path )
      @target                = Rake::Path.expand_with_root( @target, @objects_path )
      @target_type           ||= type( @target )
      raise "Building #{ @target_type } targets is not supported" if ! TARGET_TYPES.include?( @target_type )
      @install_path          ||= default_install_path( @target_type )

      @linker_options        ||= ''
      @include_paths         += []
      @include_paths         = Rake::Path.expand_all_with_root( @include_paths.uniq, @rakefile_path )
      @generated_files       = Rake::Path.expand_all_with_root( @generated_files, @rakefile_path )

      @default_task          ||= :build
      @target_prerequisites  << @rakefile

      @makedepend_file       = @objects_path + '/.' + target_basename + '.depend.mf'

      raise "No source files found" if source_files.length == 0
    end

    def define_tasks
      if @task_namespace
        namespace @task_namespace do
          define
        end
      else
        define
      end
    end

    def define_default
      name = scoped_task( @default_task )
      desc "Equivalent to 'rake #{ name }'"
      if @task_namespace
        task @task_namespace => [ name ]
      else
        task :default => [ name ]
      end
    end

    def define
      if @target_type == :executable
        desc "Run '#{ target_basename }'"
        task :run => :build do
          command = "cd #{ @rakefile_path } && #{ @target }" 
          puts shell( command, Logger::INFO )
        end
      end

      desc "Compile and build '#{ target_basename }'"
      FileTaskAlias.define_task( :build, @target )

      desc "Build '#{ target_basename }'"
      file @target => [ scoped_task( :compile ), *@target_prerequisites ] do |t|
        shell "rm -f #{ t.name }"
        build_commands.each do | command |
          shell command
        end
        raise BuildFailureError if ! File.exist?( t.name )
      end

      desc "Compile all sources"
      # Only import dependencies when we're compiling
      # otherwise makedepend gets run on e.g. 'rake -T'
      task :compile => [ @makedepend_file, scoped_task( :load_makedepend ), *object_files ]

      source_files.each do |src|
        define_compile_task( src )
      end

      directory @objects_path

      file local_config => scoped_task( :missing_headers ) do
        added_includes   = @compiler_data.include_paths( missing_headers )
        config           = { :rake_builder  => { :config_file => { :version=> '1.0' } },
                             :include_paths => added_includes }
        File.open( local_config, 'w' ) do | file |
          file.write config.to_yaml
        end
      end

      file @makedepend_file => [ scoped_task( :load_local_config ),
                                 scoped_task( :missing_headers ),
                                 @objects_path,
                                 *project_files ] do
        @logger.add( Logger::DEBUG, "Analysing dependencies" )
        command = "makedepend -f- -- #{ include_path } -- #{ file_list( source_files ) } 2>/dev/null > #{ @makedepend_file }"
        shell command
      end

      task :load_local_config => local_config do
        config = YAML.load_file( local_config )

        version = config[ :rake_builder ][ :config_file ][ :version ]
        raise "Config file version missing" if version.nil?

        config[ :include_paths ] ||= []
        @include_paths += Rake::Path.expand_all_with_root( config[ :include_paths ], @rakefile_path )
      end

      task :missing_headers => [ *generated_headers ] do
        missing_headers
      end

      # Reimplemented mkdepend file loading to make objects depend on
      # sources with the correct paths:
      # the standard rake mkdepend loader doesn't do what we want,
      # as it assumes files will be compiled in their own directory.
      task :load_makedepend => @makedepend_file do
        object_to_source = source_files.inject( {} ) do | memo, source |
          mapped_object = source.gsub( '.' + @source_file_extension, '.o' )
          memo[ mapped_object ] = source
          memo
        end
        File.open( @makedepend_file ).each_line do |line|
          next if line !~ /:\s/
          mapped_object_file = $`
          header_file = $'.gsub( "\n", '' )
          # TODO: Why does it work,
          # if I make the object (not the source) depend on the header?
          source_file = object_to_source[ mapped_object_file ]
          object_file = object_path( source_file )
          object_file_task = Rake.application[ object_file ]
          object_file_task.enhance( [ header_file ] )
        end
      end

      desc "List generated files (which are removed with 'rake #{ scoped_task( :clean ) }')"
      task :generated_files do
        puts generated_files.inspect
      end

      # Re-implement :clean locally for project and within namespace
      # Standard :clean is a singleton
      desc "Remove temporary files"
      task :clean do
        generated_files.each do |file|
          shell "rm -f #{ file }"
        end
      end

      @generated_files << @target
      @generated_files << @makedepend_file

      desc "Install '#{ target_basename }' in '#{ @install_path }'"
      task :install, [] => [ scoped_task( :build ) ] do
        destination = File.join( @install_path, target_basename )
        install( @target, destination )
        install_headers if @target_type == :static_library
      end

      desc "Uninstall '#{ target_basename }' from '#{ @install_path }'"
      task :uninstall, [] => [] do
        destination = File.join( @install_path, target_basename )
        if ! File.exist?( destination )
          @logger.add( Logger::INFO, "The file '#{ destination }' does not exist" )
          next
        end
        begin
          shell "rm '#{ destination }'", Logger::INFO
        rescue Errno::EACCES => e
          raise "You do not have premission to uninstall '#{ destination }'\nTry\n $ sudo rake #{ scoped_task( :uninstall ) }"
        end
      end
    end

    def generated_headers
      []
    end

    def scoped_task( task )
      if @task_namespace
        "#{ task_namespace }:#{ task }"
      else
        task
      end
    end

    def define_compile_task( source )
      object = object_path( source )
      @generated_files << object
      file object => [ source ] do |t|
        @logger.add( Logger::INFO, "Compiling '#{ source }'" )
        command = "#{ @compiler } -c #{ compiler_flags } -o #{ object } #{ source }"
        shell command
      end
    end

    def build_commands
      case @target_type
      when :executable
        [ "#{ @linker } #{ link_flags } -o #{ @target } #{ file_list( object_files ) }" ]
      when :static_library
        [ "ar -cq #{ @target } #{ file_list( object_files ) }",
          "ranlib #{ @target }" ]
      when :shared_library
        [ "#{ @linker } -shared -o #{ @target } #{ file_list( object_files ) } #{ link_flags }" ]
      end
    end

    def type( target )
      case target
      when /\.a/
        :static_library
      when /\.so/
        :shared_library
      else
        :executable
      end
    end

    # Discovery

    def missing_headers
      return @missing_headers if @missing_headers
      default_includes = @compiler_data.default_include_paths( @programming_language )
      all_includes     = default_includes + @include_paths
      @missing_headers = @compiler_data.missing_headers( all_includes, source_files )
    end

    # Compiling and linking parameters

    def include_path
      @include_paths.map { |p| "-I#{ p }" }.join( " " )
    end

    def compiler_flags
      flags = include_path + ' ' + compilation_options.join( ' ' )
      flags << ' ' << architecture_option if RUBY_PLATFORM =~ /darwin/i
      flags
    end

    def architecture_option
      "-arch #{ @architecture }"
    end

    def link_flags
      flags = [ @linker_options, architecture_option, library_paths_list, library_dependencies_list ]
      flags << architecture_option if RUBY_PLATFORM =~ /darwin/i
      flags.join( " " )
    end

    # Paths

    def local_config
      filename = '.rake-builder'
      Rake::Path.expand_with_root( filename, @rakefile_path )
    end

    def save_rakefile_info( block )
      if RUBY_VERSION < '1.9'
        # Hack the path from the block String representation
        @rakefile = block.to_s.match( /@([^\:]+):/ )[ 1 ]
      else
        @rakefile = block.source_location[ 0 ]
      end
      @rakefile_path = File.expand_path( File.dirname( @rakefile ) )
    end

    def object_path( source_path_name )
      o_name = File.basename( source_path_name ).gsub( '.' + @source_file_extension, '.o' )
      Rake::Path.expand_with_root( o_name, @objects_path )
    end

    def default_install_path( target_type )
      case target_type
      when :executable
        '/usr/local/bin'
      else  
        '/usr/local/lib'
      end
    end

    def target_basename
      File.basename( @target )
    end

    # Lists of files

    def find_files( paths, extension )
      files = Rake::Path.find_files( paths, extension )
      Rake::Path.expand_all_with_root( files, @rakefile_path )
    end

    # TODO: make this return a FileList, not a plain Array
    def object_files
      source_files.map { |file| object_path( file ) }
    end

    def project_files
      source_files + header_files
    end

    def file_list( files, delimiter = ' ' )
      files.join( delimiter )
    end
    
    def library_paths_list
      @library_paths.map { | path | "-L#{ path }" }.join( " " )
    end
    
    def library_dependencies_list
      @library_dependencies.map { | lib | "-l#{ lib }" }.join( " " )
    end

    def install_headers
      # TODO: make install_headers_path a configuration option
      install_headers_path = '/usr/local/include'

      project_headers.each do | installable_header |
        destination_path = File.join( install_headers_path, installable_header[ :relative_path ] )
        begin
          `mkdir -p '#{ destination_path }'`
        rescue Errno::EACCES => e
          raise "Permission denied to created directory '#{ destination_path }'"
        end
        install( installable_header[ :source_file ], destination_path )
      end
    end

    def project_headers
      @header_search_paths.reduce( [] ) do | memo, search |
        non_glob_search = ( search.match( /^([^\*\?]*)/ ) )[ 1 ]
        case
        when ( non_glob_search !~ /#{ @rakefile_path }/ )
          # Skip paths that are not inside the project
        when File.file?( search )
          full_path = Rake::Path.expand_with_root( search, @rakefile_path )
          memo << { :source_file => search, :relative_path => '' }
        when File.directory?( search )
          FileList[ search + '/*.' + @header_file_extension ].each do | pathname |
            full_path = Rake::Path.expand_with_root( pathname, @rakefile_path )
            memo << { :source_file => pathname, :relative_path => '' }
          end
        when ( search =~ /[\*\?]/ )
          FileList[ search ].each do | pathname |
            full_path = Rake::Path.expand_with_root( pathname, @rakefile_path )
            directory = File.dirname( full_path )
            relative  = Rake::Path.subtract_prefix( non_glob_search, directory )
            memo << { :source_file => pathname, :relative_path => relative }
          end
        else
          $stderr.puts "Bad search path: '${ search }'"
        end
        memo
      end
    end

    def install( source_pathname, destination_path )
      begin
        shell "cp '#{ source_pathname }' '#{ destination_path }'", Logger::INFO
      rescue Errno::EACCES => e
        source_filename = File.basename( source_pathname ) rescue '????'
        raise "You do not have permission to install '#{ source_filename }' to '#{ destination_path }'\nTry\n $ sudo rake install"
      end
    end

    def shell( command, log_level = Logger::DEBUG )
      @logger.add( log_level, command )
      `#{ command }`
    end

  end

end