
--- A builtin build system: back-end to provide a portable way of building C-based Lua modules.
local builtin = {}

-- This build driver checks LUA_INCDIR and LUA_LIBDIR on demand,
-- so that pure-Lua rocks don't need to have development headers
-- installed.
builtin.skip_lua_inc_lib_check = true

local unpack = unpack or table.unpack

local fs = require("luarocks.fs")
local path = require("luarocks.path")
local util = require("luarocks.util")
local cfg = require("luarocks.core.cfg")
local dir = require("luarocks.dir")
local deps = require("luarocks.deps")

local function autoextract_libs(external_dependencies, variables)
   if not external_dependencies then
      return nil, nil, nil
   end
   local libs = {}
   local incdirs = {}
   local libdirs = {}
   for name, data in pairs(external_dependencies) do
      if data.library then
         table.insert(libs, data.library)
         table.insert(incdirs, variables[name .. "_INCDIR"])
         table.insert(libdirs, variables[name .. "_LIBDIR"])
      end
   end
   return libs, incdirs, libdirs
end

do
   local function get_cmod_name(file)
      local fd = io.open(dir.path(fs.current_dir(), file), "r")
      if not fd then return nil end
      local data = fd:read("*a")
      fd:close()
      return (data:match("int%s+luaopen_([a-zA-Z0-9_]+)"))
   end

   local skiplist = {
      ["spec"] = true,
      [".luarocks"] = true,
      ["lua_modules"] = true,
      ["test.lua"] = true,
      ["tests.lua"] = true,
   }

   function builtin.autodetect_modules(libs, incdirs, libdirs)
      local modules = {}
      local install
      local copy_directories

      local prefix = ""
      for _, parent in ipairs({"src", "lua", "lib"}) do
         if fs.is_dir(parent) then
            fs.change_dir(parent)
            prefix = parent.."/"
            break
         end
      end

      for _, file in ipairs(fs.find()) do
         local base = file:match("^([^\\/]*)")
         if not skiplist[base] then
            local luamod = file:match("(.*)%.lua$")
            if luamod then
               modules[path.path_to_module(file)] = prefix..file
            else
               local cmod = file:match("(.*)%.c$")
               if cmod then
                  local modname = get_cmod_name(file) or path.path_to_module(file:gsub("%.c$", ".lua"))
                  modules[modname] = {
                     sources = prefix..file,
                     libraries = libs,
                     incdirs = incdirs,
                     libdirs = libdirs,
                  }
               end
            end
         end
      end

      if prefix ~= "" then
         fs.pop_dir()
      end

      local bindir = (fs.is_dir("src/bin") and "src/bin")
                  or (fs.is_dir("bin") and "bin")
      if bindir then
         install = { bin = {} }
         for _, file in ipairs(fs.list_dir(bindir)) do
            table.insert(install.bin, dir.path(bindir, file))
         end
      end

      for _, directory in ipairs({ "doc", "docs", "samples", "tests" }) do
         if fs.is_dir(directory) then
            if not copy_directories then
               copy_directories = {}
            end
            table.insert(copy_directories, directory)
         end
      end

      return modules, install, copy_directories
   end
end

--- Run a command displaying its execution on standard output.
-- @return boolean: true if command succeeds (status code 0), false
-- otherwise.
local function execute(...)
   io.stdout:write(table.concat({...}, " ").."\n")
   return fs.execute(...)
end

--- Driver function for the builtin build back-end.
-- @param rockspec table: the loaded rockspec.
-- @return boolean or (nil, string): true if no errors occurred,
-- nil and an error message otherwise.
function builtin.run(rockspec, no_install)
   assert(rockspec:type() == "rockspec")
   local compile_object, compile_library, compile_static_library

   local build = rockspec.build
   local variables = rockspec.variables
   local checked_lua_h = false

   for _, var in ipairs{ "CC", "CFLAGS", "LDFLAGS" } do
      variables[var] = variables[var] or os.getenv(var) or ""
   end

   local function add_flags(extras, flag, flags)
      if flags then
         if type(flags) ~= "table" then
            flags = { tostring(flags) }
         end
         util.variable_substitutions(flags, variables)
         for _, v in ipairs(flags) do
            table.insert(extras, flag:format(v))
         end
      end
   end

   if cfg.is_platform("mingw32") then
      compile_object = function(object, source, defines, incdirs)
         local extras = {}
         add_flags(extras, "-D%s", defines)
         add_flags(extras, "-I%s", incdirs)
         return execute(variables.CC.." "..variables.CFLAGS, "-c", "-o", object, "-I"..variables.LUA_INCDIR, source, unpack(extras))
      end
      compile_library = function(library, objects, libraries, libdirs, name)
         local extras = { unpack(objects) }
         add_flags(extras, "-L%s", libdirs)
         add_flags(extras, "-l%s", libraries)
         extras[#extras+1] = dir.path(variables.LUA_LIBDIR, variables.LUALIB)

         if variables.CC == "clang" or variables.CC == "clang-cl" then
            local exported_name = name:gsub("%.", "_")
            exported_name = exported_name:match('^[^%-]+%-(.+)$') or exported_name
            extras[#extras+1] = string.format("-Wl,-export:luaopen_%s", exported_name)
         else
            extras[#extras+1] = "-l" .. (variables.MSVCRT or "m")
         end

         local ok = execute(variables.LD.." "..variables.LDFLAGS.." "..variables.LIBFLAG, "-o", library, unpack(extras))
         return ok
      end
      --[[ TODO disable static libs until we fix the conflict in the manifest, which will take extending the manifest format.
      compile_static_library = function(library, objects, libraries, libdirs, name)
         local ok = execute(variables.AR, "rc", library, unpack(objects))
         if ok then
            ok = execute(variables.RANLIB, library)
         end
         return ok
      end
      ]]
   elseif cfg.is_platform("win32") then
      compile_object = function(object, source, defines, incdirs)
         local extras = {}
         add_flags(extras, "-D%s", defines)
         add_flags(extras, "-I%s", incdirs)
         return execute(variables.CC.." "..variables.CFLAGS, "-c", "-Fo"..object, "-I"..variables.LUA_INCDIR, source, unpack(extras))
      end
      compile_library = function(library, objects, libraries, libdirs, name)
         local extras = { unpack(objects) }
         add_flags(extras, "-libpath:%s", libdirs)
         add_flags(extras, "%s.lib", libraries)
         local basename = dir.base_name(library):gsub(".[^.]*$", "")
         local deffile = basename .. ".def"
         local def = io.open(dir.path(fs.current_dir(), deffile), "w+")
         local exported_name = name:gsub("%.", "_")
         exported_name = exported_name:match('^[^%-]+%-(.+)$') or exported_name
         def:write("EXPORTS\n")
         def:write("luaopen_"..exported_name.."\n")
         def:close()
         local ok = execute(variables.LD, "-dll", "-def:"..deffile, "-out:"..library, dir.path(variables.LUA_LIBDIR, variables.LUALIB), unpack(extras))
         local basedir = ""
         if name:find("%.") ~= nil then
            basedir = name:gsub("%.%w+$", "\\")
            basedir = basedir:gsub("%.", "\\")
         end
         local manifestfile = basedir .. basename..".dll.manifest"

         if ok and fs.exists(manifestfile) then
            ok = execute(variables.MT, "-manifest", manifestfile, "-outputresource:"..basedir..basename..".dll;2")
         end
         return ok
      end
      --[[ TODO disable static libs until we fix the conflict in the manifest, which will take extending the manifest format.
      compile_static_library = function(library, objects, libraries, libdirs, name)
         local ok = execute(variables.AR, "-out:"..library, unpack(objects))
         return ok
      end
      ]]
   else
      compile_object = function(object, source, defines, incdirs)
         local extras = {}
         add_flags(extras, "-D%s", defines)
         add_flags(extras, "-I%s", incdirs)
         return execute(variables.CC.." "..variables.CFLAGS, "-I"..variables.LUA_INCDIR, "-c", source, "-o", object, unpack(extras))
      end
      compile_library = function (library, objects, libraries, libdirs)
         local extras = { unpack(objects) }
         add_flags(extras, "-L%s", libdirs)
         if cfg.gcc_rpath then
            add_flags(extras, "-Wl,-rpath,%s", libdirs)
         end
         add_flags(extras, "-l%s", libraries)
         if cfg.link_lua_explicitly then
            extras[#extras+1] = "-L"..variables.LUA_LIBDIR
            extras[#extras+1] = "-llua"
         end
         return execute(variables.LD.." "..variables.LDFLAGS.." "..variables.LIBFLAG, "-o", library, unpack(extras))
      end
      compile_static_library = function(library, objects, libraries, libdirs, name)  -- luacheck: ignore 211
         local ok = execute(variables.AR, "rc", library, unpack(objects))
         if ok then
            ok = execute(variables.RANLIB, library)
         end
         return ok
      end
   end

   local ok, err
   local lua_modules = {}
   local lib_modules = {}
   local luadir = path.lua_dir(rockspec.name, rockspec.version)
   local libdir = path.lib_dir(rockspec.name, rockspec.version)

   local autolibs, autoincdirs, autolibdirs = autoextract_libs(rockspec.external_dependencies, rockspec.variables)

   if not build.modules then
      if rockspec:format_is_at_least("3.0") then
         local install, copy_directories
         build.modules, install, copy_directories = builtin.autodetect_modules(autolibs, autoincdirs, autolibdirs)
         build.install = build.install or install
         build.copy_directories = build.copy_directories or copy_directories
      else
         return nil, "Missing build.modules table"
      end
   end

   local compile_temp_dir

   local mkdir_cache = {}
   local function cached_make_dir(name)
      if name == "" or mkdir_cache[name] then
         return true
      end
      mkdir_cache[name] = true
      return fs.make_dir(name)
   end

   for name, info in pairs(build.modules) do
      local moddir = path.module_to_path(name)
      if type(info) == "string" then
         local ext = info:match("%.([^.]+)$")
         if ext == "lua" then
            local filename = dir.base_name(info)
            if filename == "init.lua" and not name:match("%.init$") then
               moddir = path.module_to_path(name..".init")
            else
               local basename = name:match("([^.]+)$")
               filename = basename..".lua"
            end
            local dest = dir.path(luadir, moddir, filename)
            lua_modules[info] = dest
         else
            info = {info}
         end
      end
      if type(info) == "table" then
         if not checked_lua_h then
            local ok, err, errcode = deps.check_lua_incdir(rockspec.variables)
            if not ok then
               return nil, err, errcode
            end

            if cfg.link_lua_explicitly then
               local ok, err, errcode = deps.check_lua_libdir(rockspec.variables)
               if not ok then
                  return nil, err, errcode
               end
            end
            checked_lua_h = true
         end
         local objects = {}
         local sources = info.sources
         if info[1] then sources = info end
         if type(sources) == "string" then sources = {sources} end
         if type(sources) ~= "table" then
            return nil, "error in rockspec: module '" .. name .. "' entry has no 'sources' list"
         end
         for _, source in ipairs(sources) do
            if type(source) ~= "string" then
               return nil, "error in rockspec: module '" .. name .. "' does not specify source correctly."
            end
            local object = source:gsub("%.[^.]*$", "."..cfg.obj_extension)
            if not object then
               object = source.."."..cfg.obj_extension
            end
            ok = compile_object(object, source, info.defines, info.incdirs or autoincdirs)
            if not ok then
               return nil, "Failed compiling object "..object
            end
            table.insert(objects, object)
         end

         if not compile_temp_dir then
            compile_temp_dir = fs.make_temp_dir("build-" .. rockspec.package .. "-" .. rockspec.version)
            util.schedule_function(fs.delete, compile_temp_dir)
         end

         local module_name = name:match("([^.]*)$").."."..util.matchquote(cfg.lib_extension)
         if moddir ~= "" then
            module_name = dir.path(moddir, module_name)
         end

         local build_name = dir.path(compile_temp_dir, module_name)
         local build_dir = dir.dir_name(build_name)
         cached_make_dir(build_dir)

         lib_modules[build_name] = dir.path(libdir, module_name)
         ok = compile_library(build_name, objects, info.libraries, info.libdirs or autolibdirs, name)
         if not ok then
            return nil, "Failed compiling module "..module_name
         end

         -- for backwards compatibility, try keeping a copy of the module
         -- in the old location (luasec-1.3.2-1 rockspec breaks otherwise)
         if cached_make_dir(dir.dir_name(module_name)) then
            fs.copy(build_name, module_name)
         end

         --[[ TODO disable static libs until we fix the conflict in the manifest, which will take extending the manifest format.
         module_name = name:match("([^.]*)$").."."..util.matchquote(cfg.static_lib_extension)
         if moddir ~= "" then
            module_name = dir.path(moddir, module_name)
         end
         lib_modules[module_name] = dir.path(libdir, module_name)
         ok = compile_static_library(module_name, objects, info.libraries, info.libdirs, name)
         if not ok then
            return nil, "Failed compiling static library "..module_name
         end
         ]]
      end
   end
   if not no_install then
      for _, mods in ipairs({{ tbl = lua_modules, perms = "read" }, { tbl = lib_modules, perms = "exec" }}) do
         for name, dest in pairs(mods.tbl) do
            cached_make_dir(dir.dir_name(dest))
            ok, err = fs.copy(name, dest, mods.perms)
            if not ok then
               return nil, "Failed installing "..name.." in "..dest..": "..err
            end
         end
      end
      if fs.is_dir("lua") then
         ok, err = fs.copy_contents("lua", luadir)
         if not ok then
            return nil, "Failed copying contents of 'lua' directory: "..err
         end
      end
   end
   return true
end

return builtin
