--- # Suggest-Require
--
-- This is a small library to discover which modules are importable using
-- `require`. It's useful for seeing which modules your Lua environment
-- can access. It's intended usage is in an auto-complete system for Lua.
--
-- Known to work with Lua 5.1 and LuaJIT, on Linux.
--
-- ## Usage
--
-- **Standalone**: The library be called as a script `luajit
-- suggest-require.lua` to print available packages in the Lua
-- environment.
--
-- **Library**: Import through require. A single function is be returned.
-- Calling this function will return the available packages, as a list
-- of strings.
--
-- ## Example
--
-- Replicating the script functionality of this library is as simple as:
--
--   local package_names  =  require 'suggest-require' ()
--   for _, name in ipairs(package_names) do
--       print('- '..name)
--   end
--
-- ## Changelog
-- - `1.4.0`: Updated repository   (17. April  2025)
-- - `1.3.0`: Usage and example    (6. July    2020)
-- - `1.2.0`: Unknown change       (9. January 2018)
-- - `1.1.0`: Unknown change       (Unknown)
-- - `1.0.0`: Initial version      (September  2017)
--
-- @author Jon Michael Aanes (jonjmaa@gmail.com)

local _VERSION = '1.4.2'

--------------------------------------------------------------------------------
-- Platform dependant

local PACKAGE_CONFIG_PATTERN = '([^\n]+)\n([^\n]+)\n([^\n]+)\n([^\n]+)\n([^\n]+)'

local function error_check_package_table ()
    local err_msg
    if type(package) ~= 'table' then
        err_msg = '"package" global must be a table, but was a '..type(package)..'.'
    elseif type(package.loaded) ~= 'table' then
        err_msg = '"package.loaded" must be a table, but was a '..type(package.loaded)..'.'
    elseif type(package.config) ~= 'string' then
        err_msg = '"package.config" must be a string, but was a '..type(package.config)..'.'
    elseif not package.config:find(PACKAGE_CONFIG_PATTERN) then
        err_msg = '"package.config" must fit the package.config format.'
    elseif type(package.path) ~= 'string' then
        err_msg = '"package.path" must be a string, but was a '..type(package.path)..'.'
    elseif type(package.cpath) ~= 'string' then
        err_msg = '"package.cpath" must be a string, but was a '..type(package.cpath)..'.'
    end

    if err_msg then
        error('[suggest-require]: The package table is corrupted, and suggest-require cannot procede. '..err_msg..' Please note that the package table has been deliberately messed with.', 2)
    end
end

local function package_config ()
    assert(type(package.config) == 'string')
    local l1, l2, l3, l4, l5  =  package.config:match(PACKAGE_CONFIG_PATTERN)
    return {
        dir_sep  = l1,
        temp_sep = l2,
        sub_char = l3,
        exec_dir = l4,
        ignore   = l5,
    }
end

local iterate_files_in_subfiles
function iterate_files_in_subfiles (...)
    -- This outer function is a wrapper, that lazily loads the correct
    -- version of the iterate function, based on the availability of
    -- the commands.

    if os.execute 'find -false' == 0  then
        iterate_files_in_subfiles = function (root_path)
            -- On unix
            -- Use `find` to find files in folders below the given matching.

            assert(type(root_path) == 'string')
            local start_directory = root_path:match '^(.-)?' or root_path
            local pfile       = io.popen ('find -L "'..start_directory..'" -type f ! -path \'*/\\.*\' -print0 2> /dev/null')
            local list_str    = pfile:read '*all'
                                pfile:close()

            return list_str:gmatch '[^%z]+'
        end
    else
        -- Other platforms
        error '[suggest-require]: Your platform does not possess the "find" utility, and is thus not currently supported.'
    end

    -----

    return iterate_files_in_subfiles(...)
end

--------------------------------------------------------------------------------
-- Util

local function split_string (str, split)
    assert(type(str) == 'string')
    assert(type(split) == 'string')
    --
    local seq, last_i = {}, 1
    while true do
        local next_i, after_i = str:find(split, last_i)
        if not next_i then  break  end
        seq[#seq+1], last_i = str:sub(last_i, next_i - 1), after_i + 1
    end
    seq[#seq+1] = str:sub(last_i)
    return seq
end

local function keys_of_table (t)
    -- Returns a new sequence, including only the keys from the given
    -- table.
    assert(type(t) == 'table')
    local seq = {}
    for k in pairs(t) do  seq[#seq+1] = k  end
    return seq
end

local function module_pattern_to_file_pattern (module_pattern)
    -- Escapes the module pattern, and replaces wildcards "?" with
    -- Lua's captures.
    assert(type(module_pattern) == 'string')
    local escaped = module_pattern:gsub('[%^%(%)%.%[%]%*%+%-]', function (a) return '%' .. a end)
    return string.format('^%s$', escaped:gsub(package_config().sub_char, '(.+)'))
end

local function consistent_module_wildcards (...)
    -- For paths involving several wildcards, we need to ensure they are
    -- consistent. Therefore collect them and check them through.
    -- If they are consistent, return one of them.
    local first = (...)
    if not first then  return false  end
    --
    for i = 2, select('#', ...) do
        if first ~= select(i, ...) then  return false  end
    end
    --
    return first
end

local function get_modules_fitting_path (root_path, modules)
    -- Finds modules that fit the given path, and places them in the
    -- modules table.

    assert(type(root_path) == 'string')
    assert(type(modules) == 'table')

    -- Construct a pattern for finding wildcards, in the paths of possible modules.
    local module_path_pattern = module_pattern_to_file_pattern(root_path)

    -- Look through the file list, and find probable files.
    for path in iterate_files_in_subfiles(root_path) do

        -- Find wildcards, and ensure consistency
        local wildcard  = consistent_module_wildcards(path:match(module_path_pattern))
        if wildcard then
            modules[#modules+1] = wildcard:gsub(package_config().dir_sep, '.')
        end
    end
end


--------------------------------------------------------------------------------
-- Finding Module Names

local function get_module_names_from_path_list (path_list)
    -- Given a path list embedded into a string, using the
    -- package.config format, return the modules that can be imported
    -- using "require", that fit that list.

    assert(type(path_list) == 'string')
    local paths = split_string(path_list, package_config().temp_sep)

    local modules = {}
    for _, path in pairs(paths) do
        get_modules_fitting_path(path, modules)
    end

    return modules
end

local PACKAGE_SEARCH_METHODS = {
    -- Modules already loaded:
    function ()  return keys_of_table(package.loaded)  end,

    -- Preloaded modules:
    function ()  return keys_of_table(package.preload)  end,

    -- Lua modules not loaded yet:
    function ()  return get_module_names_from_path_list(package.path)  end,

    -- C modules not loaded yet:
    function ()  return get_module_names_from_path_list(package.cpath)  end,
}

local function get_available_module_names ()
    -- Searches through the package system to determine which modules can be
    -- imported by using `require`.

    -- Returns a sequence of strings, each of which can be directly used by
    -- `require` to import the module.

    error_check_package_table()

    -- Find and De-duplicate
    local dedub = {}
    for _, method in ipairs(PACKAGE_SEARCH_METHODS) do
        for _, name in ipairs(method()) do  dedub[name] = true  end
    end

    -- Sort
    local module_names = {}
    for name in pairs(dedub) do  module_names[#module_names+1] = name  end
    table.sort(module_names)

    -- Assert that output is correctly formatted
    assert(type(module_names) == 'table')
    for i = 1, #module_names do
        assert(type(module_names[i]) == 'string')
    end

    -- Return
    return module_names
end

--------------------------------------------------------------------------------
-- Run or return

-- If run from terminal, it will display all available modules.
-- If imported from another lua file, it will return itself as a
-- library

if ... then
    return get_available_module_names
else
    local names = get_available_module_names()
    io.write 'Following modules are available: \n'
    for _, module_name in ipairs(names) do
        io.write ' -  '
        io.write(module_name)
        io.write '\n'
    end
end