Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/std/project.lua

Raw
local table = require 'std.table'

local join = table.join
local dirname = vim.fs.dirname
local normalize = vim.fs.normalize
local validate = vim.validate

local cache = {}
local markers_to_string = function(markers)
  return vim.inspect(markers)
end
local cache_key = function(path, markers, stop)
  return path .. '|' .. markers_to_string(markers) .. '|' .. stop
end
local cache_get = function(path, markers, stop)
  local key = cache_key(path, markers, stop)
  return cache[key]
end
local cache_set = function(project_root, path, markers, stop)
  local key = cache_key(path, markers, stop)
  cache[key] = project_root
end

local expand = function(path) return vim.fn.fnamemodify(path, ':p') end

--- number of max loop iterations when searching for markers
local MAX_TRAVERSAL_COUNT = 100

--- the weight of a project marker, when the likelihood is low
local MAYBE = 1
--- the weight of a project marker, when the likelihood is high
local LIKELY = 3
--- the weight of a project marker, when the likelihood is 100%
local DEFINITELY = 7

local default_markers = {
  { name = '.lsp.settings.lua', weight = DEFINITELY, },
  { name = '.editorconfig',     weight = LIKELY, },
  { name = '.git',              weight = LIKELY, },
  { name = '.gitignore',        weight = LIKELY, },
  { name = '.svn',              weight = LIKELY, },
  { name = '.jj',               weight = LIKELY, },
  { name = 'justfile',          weight = LIKELY, },
  { name = 'Makefile',          weight = LIKELY, },
  { name = 'Jenkinsfile',       weight = MAYBE, },
  { name = 'LICENSE',           weight = MAYBE, },
  { name = 'LICENSE.md',        weight = MAYBE, },
  { name = 'LICENSE.txt',       weight = MAYBE, },
  { name = 'COPYING',           weight = MAYBE, },
  { name = 'README',            weight = MAYBE, },
  { name = 'README.md',         weight = MAYBE, },
  { name = 'flake.nix',         weight = MAYBE, },
}

local function select_best_root(directory_scores)
  if #directory_scores == 0 then return nil end
  local project_root = nil
  local max_likelyhood = 0
  for _, score in ipairs(directory_scores) do
    local likelyhood = score.marker_count * (1 / score.distance_to_buffer_file)
    if likelyhood > max_likelyhood then
      max_likelyhood = likelyhood
      project_root = score.path
    end
  end
  return project_root
end

--- Determines the path to a root directory, starting from the given path.
-- Markers have a name and weight.
-- Each name is checked for existence in all directories up to the root.
-- The sum of all markers and their weight determines a directories score.
-- The directory with the highest score will be returned.
--
-- ## Example
-- Assuming you want to determine the root of your current buffer, that is part
-- of some project.
-- Buffer: `/home/user/workspace/some\_project/a/b/c/d/the\_buffer.c`
-- Project root: `/home/user/workspace/some\_project` -> contains `.git`, `README` and `Makefile`
--
-- ```lua
-- local project = require 'std.project'
-- local current_buffer = vim.api.nvim_buf_get_name(0)
-- local markers = {
--  { name = ".git", weight = MAYBE },
--  { name = "README", weight = LIKELY },
--  { name = "Makefile", weight = DEFINITELY },
-- }
-- local root = project.find_root(current_buffer, markers, vim.uv.os_homedir())
-- print(root) -- /home/user/workspace/some_project
-- ```
---@param path string? The starting path, from where to begin the search. If `nil`, the path of the current buffer is
---used.
---@param markers { name: string, weight: number }[] List of markers that define the searched root.
---@param stop string? The last directory on the path to root, when to stop searching. if `nil`, the users home
---directory is used.
---@return string? # Directory, that contains the `markers` and lies on the path from `path` to `stop`.
local function find_root(path, markers, stop)
  validate('markers', markers, 'table')
  validate('path', path, 'string', true)
  validate('stop', stop, 'string', true)

  path = path or vim.api.nvim_buf_get_name(0)
  path = normalize(expand(path))
  stop = stop or vim.uv.os_homedir()
  stop = normalize(stop)

  if not vim.uv.fs_stat(stop) then error(('the stop directory %s does not exist!'):format(stop)) end

  if #path == 0 then return nil end

  local cache_hit = cache_get(path, markers, stop)
  if cache_hit then
    return cache_hit
  end

  local directory_scores = {}
  local current_distance_to_buffer = 0
  local loop_count = 0
  --traverse the path to stop and check every directory on the way for files
  --that are named like the given markers. if markers exist, add those to the
  --score of the directory. scores of all directories with markers will be
  --processes later.
  --the stop itself never gets checked for markers!
  -- stop if we reached the user defined stop instead of the file system root
  local current_path = path
  while current_path ~= stop do
    if loop_count > MAX_TRAVERSAL_COUNT then
      break
    end

    local score = {
      path = current_path,
      marker_count = 0,
      distance_to_buffer_file = current_distance_to_buffer,
      markers = {},
    }

    local dir_entries = {}
    local handle = vim.uv.fs_scandir(current_path)
    if handle then
      while true do
        local name = vim.uv.fs_scandir_next(handle)
        if not name then break end
        dir_entries[name] = true
      end
    end

    for _, marker in ipairs(markers) do
      if dir_entries[marker.name] then
        local marker_path = current_path .. '/' .. marker.name
        score.marker_count = score.marker_count + marker.weight
        table.insert(score.markers, marker_path)
      end
    end

    if score.marker_count > 0 then
      table.insert(directory_scores, score)
    end

    current_path = dirname(current_path)

    current_distance_to_buffer = current_distance_to_buffer + 1
    loop_count = loop_count + 1
  end

  local project_root = select_best_root(directory_scores)
  if project_root then
    cache_set(project_root, path, markers, stop)
  end
  return project_root
end

--- Determines the project root directory of the current buffer file. A "project" is not clearly defined for all programming languages, so this function uses a heuristic approach.
---@param path string? path to start searching from. If `nil`, then path of current buffer is used.
---@param markers { name: string, weight: number }[]? list of weighted project markers.
---
---@return string? # The project root of the current buffer, or `nil`, if none could be determined.
---
local function find_project_root(path, markers)
  markers = markers or {}
  local all_markers = #markers == 0 and default_markers or join(default_markers, markers)
  return find_root(path, all_markers)
end

local function find_vcs_project_root(path)
  return find_project_root(path, {})
end

local function find_java_project_root(path)
  local java_markers = {
    { name = '.idea',             weight = LIKELY, },
    { name = '.classpath',        weight = LIKELY, },
    { name = '.settings',         weight = LIKELY, },
    { name = '.project',          weight = LIKELY, },
    { name = 'target',            weight = LIKELY, },
    { name = 'pom.xml',           weight = LIKELY, },
    { name = 'mvnw',              weight = DEFINITELY, },
    { name = '.mvn',              weight = DEFINITELY, },
    { name = 'mvnw.cmd',          weight = DEFINITELY, },
    { name = 'build.gradle',      weight = DEFINITELY, },
    { name = 'gradle.properties', weight = DEFINITELY, },
    { name = 'settings.gradle',   weight = DEFINITELY, },
    { name = 'gradlew',           weight = DEFINITELY, },
    { name = 'gradlew.bat',       weight = DEFINITELY, },
  }
  return find_project_root(path, java_markers)
end

local find_lua_project_root = function(path)
  local lua_markers = {
    { name = 'lua',          weight = LIKELY, },
    { name = '.luarc.json',  weight = LIKELY, },
    { name = '.luarc.jsonc', weight = LIKELY, },
    { name = '.luacheckrc',  weight = LIKELY, },
    { name = '.stylua.toml', weight = LIKELY, },
    { name = 'stylua.toml',  weight = LIKELY, },
    { name = 'selene.toml',  weight = LIKELY, },
    { name = 'selene.yml',   weight = LIKELY, },
    { name = 'neovim.yml',   weight = LIKELY, },
  }

  return find_project_root(path, lua_markers)
end

local find_zig_project_root = function(path)
  local zig_markers = {
    { name = 'build.zig',      weight = DEFINITELY, },
    { name = 'zls.build.json', weight = DEFINITELY, },
    { name = 'zig-cache',      weight = MAYBE, },
    { name = 'zig-out',        weight = MAYBE, },
  }

  return find_project_root(path, zig_markers)
end

local find_rust_project_root = function(path)
  local markers = {
    { name = 'Cargo.toml', weight = DEFINITELY, },
  }
  local cargo_crate_dir = find_project_root(path, markers)
  if cargo_crate_dir == nil then return nil end

  local cmd = {
    'cargo',
    'metadata',
    '--no-deps',
    '--format-version',
    '1',
    '--manifest-path',
    vim.fs.joinpath(cargo_crate_dir, 'Cargo.toml'),
  }

  local result = vim.system(cmd, { text = true, }):wait()
  local cargo_workspace_root

  if result.code == 0 then
    local json = vim.json.decode(result.stdout)
    if json['workspace_root'] then
      cargo_workspace_root = vim.fs.normalize(json['workspace_root'])
    end
  end

  return cargo_workspace_root or cargo_crate_dir
end

local fallback_rooter = function(path)
  return find_vcs_project_root(path) or find_project_root(path)
end
local rooters = {
  java = find_java_project_root,
  lua = find_lua_project_root,
  rust = find_rust_project_root,
  zig = find_zig_project_root,
}

local find_root_by_filetype = function(path, filetype)
  local rooter = rooters[filetype]
  return rooter and rooter(path) or fallback_rooter(path)
end

return {
  cache = cache,
  find_root = find_root,
  find_root_by_filetype = find_root_by_filetype,
  find_project_root = find_project_root,
  find_vcs_project_root = find_vcs_project_root,
  find_java_project_root = find_java_project_root,
  find_lua_project_root = find_lua_project_root,
  find_zig_project_root = find_zig_project_root,
  find_rust_project_root = find_rust_project_root,
  _select_best_root = select_best_root,
}