diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..0d8e1703 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-shebang-scripts-are-executable + - id: check-executables-have-shebangs + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.5 + hooks: + - id: remove-crlf + + - repo: local + hooks: + - id: stylua + name: StyLua + language: system + entry: stylua + types: [lua] + verbose: true diff --git a/.stylua.toml b/.stylua.toml index 9393cf9a..bd987d8d 100644 --- a/.stylua.toml +++ b/.stylua.toml @@ -1,5 +1,10 @@ -column_width = 120 -line_endings = "Unix" indent_type = "Spaces" -indent_width = 2 -quote_style = "AutoPreferDouble" +indent_width = 4 +column_width = 100 +line_endings = "Unix" +quote_style = "AutoPreferSingle" +call_parentheses = "Always" +collapse_simple_statement = "FunctionOnly" + +[sort_requires] +enabled = false diff --git a/README.md b/README.md index 69594fc4..7734c5dc 100644 --- a/README.md +++ b/README.md @@ -1,214 +1,333 @@ -# 🗃️ project.nvim +
+

🗃️ project.nvim

+
-**project.nvim** is an all in one neovim plugin written in lua that provides -superior project management. + + +`project.nvim` is an all in one [Neovim](neovim/neovim) plugin written in Lua +that provides superior project management. ![Telescope Integration](https://user-images.githubusercontent.com/36672196/129409509-62340f10-4dd0-4c1a-9252-8bfedf2a9945.png) -## ⚡ Requirements - -- Neovim >= 0.5.0 - -## ✨ Features - -- Automagically cd to project directory using nvim lsp - - Dependency free, does not rely on lspconfig -- If no lsp then uses pattern matching to cd to root directory -- Telescope integration `:Telescope projects` - - Access your recently opened projects from telescope! - - Asynchronous file io so it will not slow down vim when reading the history - file on startup. -- ~~Nvim-tree.lua support/integration~~ - - Please add the following to your config instead: - ```vim - " Vim Script - lua << EOF - require("nvim-tree").setup({ - sync_root_with_cwd = true, - respect_buf_cwd = true, - update_focused_file = { - enable = true, - update_root = true - }, - }) - EOF - ``` - ```lua - -- lua - require("nvim-tree").setup({ - sync_root_with_cwd = true, - respect_buf_cwd = true, - update_focused_file = { - enable = true, - update_root = true - }, - }) - ``` +--- + +## Table of Contents + +1. [Features](#features) +2. [Installation](#installation) + 1. [Requirements](#requirements) + 2. [`vim-plug`](#vim-plug) + 3. [`lazy.nvim`](#lazy-nvim) + 4. [`pckr.nvim`](#pckr-nvim) +3. [Configuration](#configuration) + 1. [Pattern Matching](#pattern-matching) + 2. [`nvim-tree.lua` Integration](#nvim-tree-integration) + 3. [`telescope.nvim` Integration](#telescope-integration) + 1. [Telescope Projects Picker](#telescope-projects-picker) + 2. [Telescope Mappings](#telescope-mappings) +4. [API](#api) +5. [Contributing](#contributing) +6. [Addendum](#addendum) + +--- + +## Features + +- Automagically `cd` to the project root directory using `vim.lsp` +- If no LSP is available then it'll try using pattern matching to `cd` to the project root directory instead +- [Telescope Integration](#telescope-integration) `:Telescope projects` +- Asynchronous file IO so it will not slow down neovim when reading the history file on startup. +- [`nvim-tree` integration](#nvim-tree-integration) + +## Installation + +### Requirements + +- Neovim >= 0.11.0 +- [`telescope.nvim`](nvim-telescope/telescope.nvim) **(optional)** +- [`nvim-tree.lua`](nvim-tree/nvim-tree.lua) **(optional)** + +--- + +
+WARNING: DO NOT LAZY-LOAD THIS PLUGIN -## 📦 Installation +The cwd might not update otherwise. +
+ +--- Install the plugin with your preferred package manager: -### [vim-plug](https://github.com/junegunn/vim-plug) +

+vim-plug +

```vim -" Vim Script -Plug 'ahmedkhalf/project.nvim' +Plug 'DrKJeff16/project.nvim' + +" OPTIONAL +Plug 'nvim-telescope/telescope.nvim' | Plug 'plenary.nvim' lua << EOF - require("project_nvim").setup { + require('project_nvim').setup({ -- your configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below - } + }) EOF ``` -### [packer](https://github.com/wbthomason/packer.nvim) +

+lazy.nvim +

```lua --- Lua -use { - "ahmedkhalf/project.nvim", - config = function() - require("project_nvim").setup { - -- your configuration comes here - -- or leave it empty to use the default settings - -- refer to the configuration section below - } - end -} +require('lazy').setup({ + spec = { + -- Other plugins + { + 'DrKJeff16/project.nvim', + lazy = false, -- WARN: IMPORTANT NOT TO LAZY-LOAD THIS PLUGIN + dependencies = { + 'plenary.nvim', + 'nvim-telescope/telescope.nvim', + }, -- OPTIONAL + config = function() + require('project_nvim').setup({ + -- your configuration comes here + -- or leave it empty to use the default settings + -- refer to the configuration section below + }) + end, + }, + }, +}) +``` + +

+pckr.nvim +

+ +```lua +require('pckr').add({ + -- Other plugins + { + 'DrKJeff16/project.nvim', + requires = { + 'nvim-lua/plenary.nvim', + 'nvim-telescope/telescope.nvim', + }, -- OPTIONAL + config = function() + require('project_nvim').setup({ + -- your configuration comes here + -- or leave it empty to use the default settings + -- refer to the configuration section below + }) + end, + }; +}) ``` -## ⚙️ Configuration +--- -**project.nvim** comes with the following defaults: +## Configuration + +To enable the plugin you must call `setup()`: + +```lua +require('project_nvim').setup({ + -- Options +}) +``` + +`project.nvim` comes with the following defaults: ```lua { -- Manual mode doesn't automatically change your root directory, so you have -- the option to manually do so using `:ProjectRoot` command. + ---@type boolean manual_mode = false, - -- Methods of detecting the root directory. **"lsp"** uses the native neovim - -- lsp, while **"pattern"** uses vim-rooter like glob pattern matching. Here + -- Methods of detecting the root directory. **'lsp'** uses the native neovim + -- LSP, while **'pattern'** uses vim-rooter like glob pattern matching. Here -- order matters: if one is not detected, the other is used as fallback. You - -- can also delete or rearangne the detection methods. - detection_methods = { "lsp", "pattern" }, - - -- All the patterns used to detect root dir, when **"pattern"** is in - -- detection_methods - patterns = { ".git", "_darcs", ".hg", ".bzr", ".svn", "Makefile", "package.json" }, - - -- Table of lsp clients to ignore by name - -- eg: { "efm", ... } + -- can also delete or rearrange the detection methods. + ---@type ('lsp'|'pattern')[]|table + detection_methods = { 'lsp', 'pattern' }, + + -- All the patterns used to detect root dir, when `'pattern'` is in + -- `detection_methods` + ---@type string[] + patterns = { + '.git', + '.github', + '_darcs', + '.hg', + '.bzr', + '.svn', + 'package.json', + }, + + -- Table of LSP clients to ignore by name + -- eg: { 'efm', ... } + ---@type string[]|table ignore_lsp = {}, -- Don't calculate root dir on specific directories - -- Ex: { "~/.cargo/*", ... } + -- Ex: { '~/.cargo/*', ... } + ---@type string[]|table exclude_dirs = {}, -- Show hidden files in telescope + ---@type boolean show_hidden = false, -- When set to false, you will get a message when project.nvim changes your -- directory. + ---@type boolean silent_chdir = true, -- What scope to change the directory, valid options are -- * global (default) -- * tab -- * win + ---@type 'global'|'tab'|'win' scope_chdir = 'global', -- Path where project.nvim will store the project history for use in -- telescope - datapath = vim.fn.stdpath("data"), + ---@type string + datapath = vim.fn.stdpath('data'), } ``` -Even if you are pleased with the defaults, please note that `setup {}` must be +Even if you are pleased with the defaults, please note that `setup()` must be called for the plugin to start. ### Pattern Matching -**project.nvim**'s pattern engine uses the same expressions as vim-rooter, but -for your convenience, I will copy paste them here: - -To specify the root is a certain directory, prefix it with `=`. +`project.nvim` comes with a pattern matching engine that uses the same expressions +as `vim-rooter`, but for your convenience here come some examples: + +- To specify the root is a certain directory, prefix it with `=`: + ```lua + patterns = { '=src' } + ``` +- To specify the root has a certain directory or file (which may be a glob), just + add it to the pattern list: + ```lua + patterns = { '.git', '.github', '*.sln', 'build/env.sh' } + ``` +- To specify the root has a certain directory as an ancestor (useful for + excluding directories), prefix it with `^`: + ```lua + patterns = { '^fixtures' } + ``` +- To specify the root has a certain directory as its direct ancestor / parent + (useful when you put working projects in a common directory), prefix it with `>`: + ```lua + patterns = { '>Latex' } + ``` +- To exclude a pattern, prefix it with `!`: + ```lua + patterns = { '!.git/worktrees', '!=extras', '!^fixtures', '!build/env.sh' } + ``` + +**NOTE**: Make sure to put your pattern exclusions first, and then the patterns you do want included. + +

+nvim-tree.lua Integration +

+ +Make sure these flags are enabled to support [`nvim-tree.lua`](nvim-tree/nvim-tree.lua): ```lua -patterns = { "=src" } +require('nvim-tree').setup({ + sync_root_with_cwd = true, + respect_buf_cwd = true, + update_focused_file = { + enable = true, + update_root = true, + }, +}) ``` -To specify the root has a certain directory or file (which may be a glob), just -give the name: +

+telescope.nvim Integration +

+ +To enable [`telescope.nvim`](nvim-telescope/telescope.nvim) integration use the following +code in your config: ```lua -patterns = { ".git", "Makefile", "*.sln", "build/env.sh" } +require('telescope').setup(...) +-- Other stuff may come here... +require('telescope').load_extension('projects') ``` -To specify the root has a certain directory as an ancestor (useful for -excluding directories), prefix it with `^`: +After that you can now call it from the command line: -```lua -patterns = { "^fixtures" } +```vim +:Telescope projects ``` -To specify the root has a certain directory as its direct ancestor / parent -(useful when you put working projects in a common directory), prefix it with -`>`: +#### Telescope Projects Picker + +To use the projects picker execute the following Lua code: ```lua -patterns = { ">Latex" } +require('telescope').extensions.projects.projects() ``` -To exclude a pattern, prefix it with `!`. +#### Telescope Mappings -```lua -patterns = { "!.git/worktrees", "!=extras", "!^fixtures", "!build/env.sh" } -``` +`project.nvim` comes with the following mappings for Telescope: -List your exclusions before the patterns you do want. +| Normal mode | Insert mode | Action | +| ----------- | ----------- | -------------------------- | +| f | \ | `find_project_files` | +| b | \ | `browse_project_files` | +| d | \ | `delete_project` | +| s | \ | `search_in_project_files` | +| r | \ | `recent_project_files` | +| w | \ | `change_working_directory` | -### Telescope Integration +--- -To enable telescope integration: -```lua -require('telescope').load_extension('projects') -``` +## API + +You can get a list of recent projects by running the code below: -#### Telescope Projects Picker -To use the projects picker ```lua -require'telescope'.extensions.projects.projects{} +-- Using `vim.notify()` +vim.notify( + vim.inspect(require('project_nvim').get_recent_projects()), + vim.log.levels.INFO +) + +-- Using `vim.print()` +vim.print( + vim.inspect(require('project_nvim').get_recent_projects()) +) ``` -#### Telescope mappings +Where `get_recent_projects()` returns either an empty table `{}` or a string array `{ '/path/to/project', ... }` -**project.nvim** comes with the following mappings: +--- -| Normal mode | Insert mode | Action | -| ----------- | ----------- | -------------------------- | -| f | \ | find\_project\_files | -| b | \ | browse\_project\_files | -| d | \ | delete\_project | -| s | \ | search\_in\_project\_files | -| r | \ | recent\_project\_files | -| w | \ | change\_working\_directory | - -## API +## Contributing -Get a list of recent projects: +- All pull requests are welcome +- If you encounter bugs please open an issue -```lua -local project_nvim = require("project_nvim") -local recent_projects = project_nvim.get_recent_projects() +--- -print(vim.inspect(recent_projects)) -``` +## Addendum -## 🤝 Contributing +(DrKJeff16) Thanks for the support to this fork <3 -- All pull requests are welcome. -- If you encounter bugs please open an issue. +Also, thanks to the original creator, [ahmedkhalf](https://github.com/ahmedkhalf)! diff --git a/lua/project_nvim/config.lua b/lua/project_nvim/config.lua index 36e3027f..66d1c042 100644 --- a/lua/project_nvim/config.lua +++ b/lua/project_nvim/config.lua @@ -1,66 +1,147 @@ -local M = {} +---@diagnostic disable:missing-fields ----@class ProjectOptions -M.defaults = { - -- Manual mode doesn't automatically change your root directory, so you have - -- the option to manually do so using `:ProjectRoot` command. - manual_mode = false, +local glob = require('project_nvim.utils.globtopattern') - -- Methods of detecting the root directory. **"lsp"** uses the native neovim - -- lsp, while **"pattern"** uses vim-rooter like glob pattern matching. Here - -- order matters: if one is not detected, the other is used as fallback. You - -- can also delete or rearangne the detection methods. - detection_methods = { "lsp", "pattern" }, +---@class Project.Config.Options +-- If `true` your root directory won't be changed automatically, +-- so you have the option to manually do so using `:ProjectRoot` command. +-- --- +-- Default: `false` +-- --- +---@field manual_mode? boolean +-- Methods of detecting the root directory. `'lsp'` uses the native neovim +-- lsp, while `'pattern'` uses vim-rooter like glob pattern matching. Here +-- order matters: if one is not detected, the other is used as fallback. You +-- can also delete or rearrange the detection methods. +-- --- +-- Default: `{ 'lsp' , 'pattern' }` +-- --- +---@field detection_methods? ('lsp'|'pattern')[] +-- All the patterns used to detect root dir, when **'pattern'** is in +-- detection_methods +-- --- +-- Default: +-- ```lua +-- { +-- '.git', +-- '.github', +-- '_darcs', +-- '.hg', +-- '.bzr', +-- '.svn', +-- 'package.json', +-- '.stylua.toml', +-- 'stylua.toml', +-- } +-- ``` +-- --- +-- See `:h project.nvim-pattern-matching` +-- --- +---@field patterns? string[] +-- Table of lsp clients to ignore by name +-- e.g. `{ 'efm', ... }` +-- --- +-- Default: `{}` +-- --- +-- If you have `nvim-lspconfig` installed **see** `:h lspconfig-all` +-- for a list of servers +-- --- +---@field ignore_lsp? string[] +-- Don't calculate root dir on specific directories +-- e.g. `{ '~/.cargo/*', ... }` +-- --- +-- Default: `{}` +-- --- +---@field exclude_dirs? string[] +-- Make hidden files visible when using the `telescope` picker +-- --- +-- Default: `false` +-- --- +---@field show_hidden? boolean +-- If `false`, you'll get a _notification_ every time +-- `project.nvim` changes directory +-- --- +-- Default: `true` +-- --- +---@field silent_chdir? boolean +-- What scope to change the directory, valid options are +-- * `global` +-- * `tab` +-- * `win` +-- --- +-- Default: `'global'` +-- --- +---@field scope_chdir? 'global'|'tab'|'win' +-- Path where `project.nvim` will store the project history for +-- future use with the `telescope` picker +-- --- +-- Default: `vim.fn.stdpath('data')` +-- --- +---@field datapath? string - -- All the patterns used to detect root dir, when **"pattern"** is in - -- detection_methods - patterns = { ".git", "_darcs", ".hg", ".bzr", ".svn", "Makefile", "package.json" }, +---@class Project.Config +---@field defaults Project.Config.Options +-- Options defined after running `require('project_nvim').setup()` +-- --- +-- Default: `{}` (before calling `setup()`) +-- --- +---@field options table|Project.Config.Options +-- The function called when running `require('project_nvim').setup()` +-- --- +---@field setup fun(options: table|Project.Config.Options?) - -- Table of lsp clients to ignore by name - -- eg: { "efm", ... } - ignore_lsp = {}, +---@param pattern string +---@return string +local function pattern_exclude(pattern) + local HOME_EXPAND = vim.fn.expand('~') - -- Don't calculate root dir on specific directories - -- Ex: { "~/.cargo/*", ... } - exclude_dirs = {}, + if vim.startswith(pattern, '~/') then + pattern = string.format('%s/%s', HOME_EXPAND, pattern:sub(3, #pattern)) + end - -- Show hidden files in telescope - show_hidden = false, + return glob.globtopattern(pattern) +end - -- When set to false, you will get a message when project.nvim changes your - -- directory. - silent_chdir = true, +---@type Project.Config +local Config = {} +Config.defaults = { + manual_mode = false, + detection_methods = { 'lsp', 'pattern' }, - -- What scope to change the directory, valid options are - -- * global (default) - -- * tab - -- * win - scope_chdir = 'global', + patterns = { + '.git', + '.github', + '_darcs', + '.hg', + '.bzr', + '.svn', + 'package.json', + '.stylua.toml', + 'stylua.toml', + }, - -- Path where project.nvim will store the project history for use in - -- telescope - datapath = vim.fn.stdpath("data"), + ignore_lsp = {}, + exclude_dirs = {}, + show_hidden = false, + silent_chdir = true, + scope_chdir = 'global', + datapath = vim.fn.stdpath('data'), } ----@type ProjectOptions -M.options = {} +Config.options = {} -M.setup = function(options) - M.options = vim.tbl_deep_extend("force", M.defaults, options or {}) +---@param options? table|Project.Config.Options +function Config.setup(options) + options = type(options) == 'table' and options or {} - local glob = require("project_nvim.utils.globtopattern") - local home = vim.fn.expand("~") - M.options.exclude_dirs = vim.tbl_map(function(pattern) - if vim.startswith(pattern, "~/") then - pattern = home .. "/" .. pattern:sub(3, #pattern) - end - return glob.globtopattern(pattern) - end, M.options.exclude_dirs) + Config.options = vim.tbl_deep_extend('keep', options, Config.defaults) + Config.options.exclude_dirs = vim.tbl_map(pattern_exclude, Config.options.exclude_dirs) - vim.opt.autochdir = false -- implicitly unset autochdir + -- Implicitly unset autochdir + vim.opt.autochdir = false - require("project_nvim.utils.path").init() - require("project_nvim.project").init() + require('project_nvim.utils.path').init() + require('project_nvim.project').init() end -return M +return Config diff --git a/lua/project_nvim/init.lua b/lua/project_nvim/init.lua index ab45fb33..0581fb18 100644 --- a/lua/project_nvim/init.lua +++ b/lua/project_nvim/init.lua @@ -1,8 +1,16 @@ -local config = require("project_nvim.config") -local history = require("project_nvim.utils.history") -local M = {} +---@diagnostic disable:missing-fields -M.setup = config.setup -M.get_recent_projects = history.get_recent_projects +local config = require('project_nvim.config') +local history = require('project_nvim.utils.history') -return M +---@class Project +---@field setup fun(options: table|Project.Config.Options) +---@field get_recent_projects fun(): table|string[] + +---@type Project +local Project = {} + +Project.setup = config.setup +Project.get_recent_projects = history.get_recent_projects + +return Project diff --git a/lua/project_nvim/project.lua b/lua/project_nvim/project.lua index 76972eec..e1b2692e 100644 --- a/lua/project_nvim/project.lua +++ b/lua/project_nvim/project.lua @@ -1,286 +1,362 @@ -local config = require("project_nvim.config") -local history = require("project_nvim.utils.history") -local glob = require("project_nvim.utils.globtopattern") -local path = require("project_nvim.utils.path") -local uv = vim.loop +---@diagnostic disable:missing-fields + +local config = require('project_nvim.config') +local history = require('project_nvim.utils.history') +local glob = require('project_nvim.utils.globtopattern') +local path = require('project_nvim.utils.path') + +local uv = vim.uv or vim.loop + +-- TODO: (DrKJeff16) Figure out a more appropriate name +---@class Project.LSP +---@field init fun() +---@field attached_lsp boolean +---@field last_project string? +---@field find_lsp_root fun(): (string?,string?) +---@field find_pattern_root fun(): ((string|nil),string?) +---@field on_attach_lsp fun(client: vim.lsp.Client, bufnr: integer) +---@field attach_to_lsp fun(): (integer?,string?) +---@field set_pwd fun(dir: string, method: string): boolean? +---@field get_project_root fun(): (string?,string?) +---@field is_file fun(): boolean +---@field on_buf_enter fun() +---@field add_project_manually fun() + +local in_tbl = vim.tbl_contains + +---@type Project.LSP local M = {} -- Internal states M.attached_lsp = false M.last_project = nil +---@return (string|nil),string? function M.find_lsp_root() - -- Get lsp client for current buffer - -- Returns nil or string - local buf_ft = vim.api.nvim_buf_get_option(0, "filetype") - local clients = vim.lsp.buf_get_clients() - if next(clients) == nil then - return nil - end - - for _, client in pairs(clients) do - local filetypes = client.config.filetypes - if filetypes and vim.tbl_contains(filetypes, buf_ft) then - if not vim.tbl_contains(config.options.ignore_lsp, client.name) then - return client.config.root_dir, client.name - end + -- Get lsp client for current buffer + -- Returns nil or string + local bufnr = vim.api.nvim_get_current_buf() + + local clients = vim.lsp.get_clients({ bufnr = bufnr }) + if next(clients) == nil then + return nil end - end - return nil -end + local ft = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) -function M.find_pattern_root() - local search_dir = vim.fn.expand("%:p:h", true) - if vim.fn.has("win32") > 0 then - search_dir = search_dir:gsub("\\", "/") - end - - local last_dir_cache = "" - local curr_dir_cache = {} - - local function get_parent(path) - path = path:match("^(.*)/") - if path == "" then - path = "/" - end - return path - end + for _, client in next, clients do + ---@type table|string[] + ---@diagnostic disable-next-line:undefined-field + local filetypes = client.config.filetypes -- For whatever reason this field is not declared in the type... - local function get_files(file_dir) - last_dir_cache = file_dir - curr_dir_cache = {} + if type(filetypes) ~= 'table' or vim.tbl_isempty(filetypes) then + goto continue + end - local dir = uv.fs_scandir(file_dir) - if dir == nil then - return + if in_tbl(filetypes, ft) and not in_tbl(config.options.ignore_lsp, client.name) then + return client.config.root_dir, client.name + end + + ::continue:: end - while true do - local file = uv.fs_scandir_next(dir) - if file == nil then - return - end + return nil +end - table.insert(curr_dir_cache, file) +---@return (string|nil),string? +function M.find_pattern_root() + local search_dir = vim.fn.expand('%:p:h', true) + if vim.fn.has('win32') > 0 then + search_dir = search_dir:gsub('\\', '/') end - end - local function is(dir, identifier) - dir = dir:match(".*/(.*)") - return dir == identifier - end + local last_dir_cache = '' + ---@type string[] + local curr_dir_cache = {} - local function sub(dir, identifier) - local path = get_parent(dir) - while true do - if is(path, identifier) then - return true - end - local current = path - path = get_parent(path) - if current == path then - return false - end + ---@param path_str string + ---@return string path_str + local function get_parent(path_str) + path_str = path_str:match('^(.*)/') + + return (path_str ~= '') and path_str or '/' end - end - local function child(dir, identifier) - local path = get_parent(dir) - return is(path, identifier) - end + ---@param file_dir string + local function get_files(file_dir) + last_dir_cache = file_dir + curr_dir_cache = {} + + ---@type uv_fs_t|nil + local dir = uv.fs_scandir(file_dir) + if dir == nil then + return + end + + ---@type string|nil + local file + + while true do + file = uv.fs_scandir_next(dir) + if file == nil then + return + end - local function has(dir, identifier) - if last_dir_cache ~= dir then - get_files(dir) + table.insert(curr_dir_cache, file) + end end - local pattern = glob.globtopattern(identifier) - for _, file in ipairs(curr_dir_cache) do - if file:match(pattern) ~= nil then - return true - end + + ---@param dir string + ---@param identifier string + ---@return boolean + local function is(dir, identifier) return dir:match('.*/(.*)') == identifier end + + ---@param dir string + ---@param identifier string + local function sub(dir, identifier) + local path_str = get_parent(dir) + local current = '' + + -- FIXME: (DrKJeff16) This loop is dangerous, even if halting cond is supposedly known + while true do + if is(path_str, identifier) then + return true + end + current = path_str + path_str = get_parent(path_str) + + if current == path_str then + return false + end + end end - return false - end - - local function match(dir, pattern) - local first_char = pattern:sub(1, 1) - if first_char == "=" then - return is(dir, pattern:sub(2)) - elseif first_char == "^" then - return sub(dir, pattern:sub(2)) - elseif first_char == ">" then - return child(dir, pattern:sub(2)) - else - return has(dir, pattern) + + ---@param dir string + ---@param identifier string + local function child(dir, identifier) return is(get_parent(dir), identifier) end + + ---@param dir string + ---@param identifier string + local function has(dir, identifier) + if last_dir_cache ~= dir then + get_files(dir) + end + local pattern = glob.globtopattern(identifier) + for _, file in next, curr_dir_cache do + if file:match(pattern) ~= nil then + return true + end + end + return false end - end - - -- breadth-first search - while true do - for _, pattern in ipairs(config.options.patterns) do - local exclude = false - if pattern:sub(1, 1) == "!" then - exclude = true - pattern = pattern:sub(2) - end - if match(search_dir, pattern) then - if exclude then - break + + ---@param dir string + ---@param pattern string + ---@return boolean + local function match(dir, pattern) + local first_char = pattern:sub(1, 1) + if first_char == '=' then + return is(dir, pattern:sub(2)) + elseif first_char == '^' then + return sub(dir, pattern:sub(2)) + elseif first_char == '>' then + return child(dir, pattern:sub(2)) else - return search_dir, "pattern " .. pattern + return has(dir, pattern) end - end end - local parent = get_parent(search_dir) - if parent == search_dir or parent == nil then - return nil - end + -- FIXME: (DrKJeff16) This loop is dangerous, even if halting cond is supposedly known + -- breadth-first search + while true do + for _, pattern in next, config.options.patterns do + local exclude = false + if pattern:sub(1, 1) == '!' then + exclude = true + pattern = pattern:sub(2) + end + if match(search_dir, pattern) then + if exclude then + break + else + return search_dir, 'pattern ' .. pattern + end + end + end + + local parent = get_parent(search_dir) + if parent == search_dir or parent == nil then + return nil + end - search_dir = parent - end + search_dir = parent + end end ----@diagnostic disable-next-line: unused-local -local on_attach_lsp = function(client, bufnr) - M.on_buf_enter() -- Recalculate root dir after lsp attaches +---@param client vim.lsp.Client +---@param bufnr integer +local function on_attach_lsp(client, bufnr) + M.on_buf_enter() -- Recalculate root dir after lsp attaches end +-- WARN: (DrKJeff16) Honestly I'm not feeling good about this one, chief +---@return integer?,string? function M.attach_to_lsp() - if M.attached_lsp then - return - end - - local _start_client = vim.lsp.start_client - vim.lsp.start_client = function(lsp_config) - if lsp_config.on_attach == nil then - lsp_config.on_attach = on_attach_lsp - else - local _on_attach = lsp_config.on_attach - lsp_config.on_attach = function(client, bufnr) - on_attach_lsp(client, bufnr) - _on_attach(client, bufnr) - end + if M.attached_lsp then + return end - return _start_client(lsp_config) - end - M.attached_lsp = true + -- Backup old `start_client` function + local _start_client = vim.lsp.start_client + + ---@param lsp_config vim.lsp.ClientConfig + vim.lsp.start_client = function(lsp_config) + if lsp_config.on_attach == nil then + lsp_config.on_attach = on_attach_lsp + else + local _on_attach = lsp_config.on_attach + lsp_config.on_attach = function(client, bufnr) + on_attach_lsp(client, bufnr) + _on_attach(client, bufnr) + end + end + return _start_client(lsp_config) + end + + M.attached_lsp = true end +---@param dir string +---@param method string +---@return boolean? function M.set_pwd(dir, method) - if dir ~= nil then + if dir == nil then + return false + end + M.last_project = dir table.insert(history.session_projects, dir) if vim.fn.getcwd() ~= dir then - local scope_chdir = config.options.scope_chdir - if scope_chdir == 'global' then - vim.api.nvim_set_current_dir(dir) - elseif scope_chdir == 'tab' then - vim.cmd('tcd ' .. dir) - elseif scope_chdir == 'win' then - vim.cmd('lcd ' .. dir) - else - return - end + local scope_chdir = config.options.scope_chdir + if scope_chdir == 'global' then + vim.api.nvim_set_current_dir(dir) + elseif scope_chdir == 'tab' then + vim.cmd('tcd ' .. dir) + elseif scope_chdir == 'win' then + vim.cmd('lcd ' .. dir) + else + return + end - if config.options.silent_chdir == false then - vim.notify("Set CWD to " .. dir .. " using " .. method) - end + if not config.options.silent_chdir then + vim.notify(string.format('Set CWD to %s using %s\n', dir, method), vim.log.levels.INFO) + end end - return true - end - return false + return true end +---@return (string|nil),string? function M.get_project_root() - -- returns project root, as well as method - for _, detection_method in ipairs(config.options.detection_methods) do - if detection_method == "lsp" then - local root, lsp_name = M.find_lsp_root() - if root ~= nil then - return root, '"' .. lsp_name .. '"' .. " lsp" - end - elseif detection_method == "pattern" then - local root, method = M.find_pattern_root() - if root ~= nil then - return root, method - end + -- returns project root, as well as method + for _, detection_method in next, config.options.detection_methods do + if detection_method == 'lsp' then + local root, lsp_name = M.find_lsp_root() + if root ~= nil then + return root, string.format('"%s" lsp\n', lsp_name) + end + elseif detection_method == 'pattern' then + local root, method = M.find_pattern_root() + if root ~= nil then + return root, method + end + end end - end + + return nil end +---@return boolean function M.is_file() - local buf_type = vim.api.nvim_buf_get_option(0, "buftype") - - local whitelisted_buf_type = { "", "acwrite" } - local is_in_whitelist = false - for _, wtype in ipairs(whitelisted_buf_type) do - if buf_type == wtype then - is_in_whitelist = true - break + local bufnr = vim.api.nvim_get_current_buf() + + local buf_type = vim.api.nvim_get_option_value('buftype', { buf = bufnr }) + + local whitelisted_buf_type = { '', 'acwrite' } + for _, wtype in next, whitelisted_buf_type do + if buf_type == wtype then + return true + end end - end - if not is_in_whitelist then - return false - end - return true + return false end function M.on_buf_enter() - if vim.v.vim_did_enter == 0 then - return - end + if vim.v.vim_did_enter == 0 then + return + end - if not M.is_file() then - return - end + if not M.is_file() then + return + end - local current_dir = vim.fn.expand("%:p:h", true) - if not path.exists(current_dir) or path.is_excluded(current_dir) then - return - end + local current_dir = vim.fn.expand('%:p:h', true) + if not path.exists(current_dir) or path.is_excluded(current_dir) then + return + end - local root, method = M.get_project_root() - M.set_pwd(root, method) + local root, method = M.get_project_root() + M.set_pwd(root, method) end function M.add_project_manually() - local current_dir = vim.fn.expand("%:p:h", true) - M.set_pwd(current_dir, 'manual') + local current_dir = vim.fn.expand('%:p:h', true) + M.set_pwd(current_dir, 'manual') end function M.init() - local autocmds = {} - if not config.options.manual_mode then - autocmds[#autocmds + 1] = 'autocmd VimEnter,BufEnter * ++nested lua require("project_nvim.project").on_buf_enter()' - - if vim.tbl_contains(config.options.detection_methods, "lsp") then - M.attach_to_lsp() + ---@type { integer: string|string[], integer: vim.api.keyset.create_autocmd }[] + local autocmds = {} + + -- Create the augroup, clear it + local augroup = vim.api.nvim_create_augroup('project_nvim', { clear = true }) + + if not config.options.manual_mode then + table.insert(autocmds, { + { 'VimEnter', 'BufEnter', 'WinEnter' }, + { + pattern = '*', + group = augroup, + nested = true, + callback = function() M.on_buf_enter() end, + }, + }) + + if in_tbl(config.options.detection_methods, 'lsp') then + M.attach_to_lsp() + end end - end - - vim.cmd([[ - command! ProjectRoot lua require("project_nvim.project").on_buf_enter() - command! AddProject lua require("project_nvim.project").add_project_manually() - ]]) - autocmds[#autocmds + 1] = - 'autocmd VimLeavePre * lua require("project_nvim.utils.history").write_projects_to_history()' - - vim.cmd([[augroup project_nvim - au! - ]]) - for _, value in ipairs(autocmds) do - vim.cmd(value) - end - vim.cmd("augroup END") + -- TODO(DrKJeff16): Rewrite this statement using Lua + vim.cmd('command! ProjectRoot lua require("project_nvim.project").on_buf_enter()') + vim.cmd('command! AddProject lua require("project_nvim.project").add_project_manually()') + + table.insert(autocmds, { + 'VimLeavePre', + { + pattern = '*', + group = augroup, + callback = function() history.write_projects_to_history() end, + }, + }) + + for _, value in next, autocmds do + vim.api.nvim_create_autocmd(value[1], value[2]) + end - history.read_projects_from_history() + history.read_projects_from_history() end return M diff --git a/lua/project_nvim/utils/globtopattern.lua b/lua/project_nvim/utils/globtopattern.lua index 1d784cae..99d04ad9 100644 --- a/lua/project_nvim/utils/globtopattern.lua +++ b/lua/project_nvim/utils/globtopattern.lua @@ -1,139 +1,153 @@ -- Credits for this module goes to: David Manura -- https://github.com/davidm/lua-glob-pattern -local M = { _TYPE = "module", _NAME = "globtopattern", _VERSION = "0.2.1.20120406" } +---@class Project.Utils.GlobPattern +---@field _TYPE string +---@field _NAME string +---@field _VERSION string +---@field globtopattern fun(g: string): (p: string) -function M.globtopattern(g) - -- Some useful references: - -- - apr_fnmatch in Apache APR. For example, - -- http://apr.apache.org/docs/apr/1.3/group__apr__fnmatch.html - -- which cites POSIX 1003.2-1992, section B.6. +---@type Project.Utils.GlobPattern +---@diagnostic disable-next-line:missing-fields +local M = { _TYPE = 'module', _NAME = 'globtopattern', _VERSION = '0.2.1.20120406' } - local p = "^" -- pattern being built - local i = 0 -- index in g - local c -- char at index i in g. +-- Some useful references: +-- - apr_fnmatch in Apache APR. For example, +-- http://apr.apache.org/docs/apr/1.3/group__apr__fnmatch.html +-- which cites POSIX 1003.2-1992, section B.6. +---@param g string +---@return string p +function M.globtopattern(g) + local p = '^' -- pattern being built + local i = 0 -- index in g + local c = '' -- char at index i in g. - -- unescape glob char - local function unescape() - if c == "\\" then - i = i + 1 - c = g:sub(i, i) - if c == "" then - p = "[^]" - return false - end + -- unescape glob char + ---@return boolean + local function unescape() + if c == '\\' then + i = i + 1 + c = g:sub(i, i) + if c == '' then + p = '[^]' + return false + end + end + return true end - return true - end - -- escape pattern char - local function escape(c) - return c:match("^%w$") and c or "%" .. c - end + -- escape pattern char + ---@param char string + ---@return string + local function escape(char) return char:match('^%w$') and c or '%' .. c end - -- Convert tokens at end of charset. - local function charset_end() - while 1 do - if c == "" then - p = "[^]" - return false - elseif c == "]" then - p = p .. "]" - break - else - if not unescape() then - break + -- TODO(DrKJeff16): Let's simplify this in the future + -- Convert tokens at end of charset. + ---@return boolean + local function charset_end() + while true do + if c == '' then + p = '[^]' + return false + elseif c == ']' then + p = p .. ']' + break + else + if not unescape() then + break + end + local c1 = c + i = i + 1 + c = g:sub(i, i) + if c == '' then + p = '[^]' + return false + elseif c == '-' then + i = i + 1 + c = g:sub(i, i) + if c == '' then + p = '[^]' + return false + elseif c == ']' then + p = p .. escape(c1) .. '%-]' + break + else + if not unescape() then + break + end + p = p .. escape(c1) .. '-' .. escape(c) + end + elseif c == ']' then + p = p .. escape(c1) .. ']' + break + else + p = p .. escape(c1) + i = i - 1 -- put back + end + end + i = i + 1 + c = g:sub(i, i) end - local c1 = c + return true + end + + -- Convert tokens in charset. + ---@return boolean + local function charset() i = i + 1 c = g:sub(i, i) - if c == "" then - p = "[^]" - return false - elseif c == "-" then - i = i + 1 - c = g:sub(i, i) - if c == "" then - p = "[^]" + if c == '' or c == ']' then + p = '[^]' return false - elseif c == "]" then - p = p .. escape(c1) .. "%-]" - break - else - if not unescape() then - break + elseif c == '^' or c == '!' then + i = i + 1 + c = g:sub(i, i) + if c == ']' then + -- ignored + else + p = p .. '[^' + if not charset_end() then + return false + end end - p = p .. escape(c1) .. "-" .. escape(c) - end - elseif c == "]" then - p = p .. escape(c1) .. "]" - break else - p = p .. escape(c1) - i = i - 1 -- put back + p = p .. '[' + if not charset_end() then + return false + end end - end - i = i + 1 - c = g:sub(i, i) + return true end - return true - end - -- Convert tokens in charset. - local function charset() - i = i + 1 - c = g:sub(i, i) - if c == "" or c == "]" then - p = "[^]" - return false - elseif c == "^" or c == "!" then - i = i + 1 - c = g:sub(i, i) - if c == "]" then - -- ignored - else - p = p .. "[^" - if not charset_end() then - return false + -- Convert tokens. + while true do + i = i + 1 + c = g:sub(i, i) + if c == '' then + p = p .. '$' + break + elseif c == '?' then + p = p .. '.' + elseif c == '*' then + p = p .. '.*' + elseif c == '[' then + if not charset() then + break + end + elseif c == '\\' then + i = i + 1 + c = g:sub(i, i) + if c == '' then + p = p .. '\\$' + break + end + p = p .. escape(c) + else + p = p .. escape(c) end - end - else - p = p .. "[" - if not charset_end() then - return false - end end - return true - end - -- Convert tokens. - while 1 do - i = i + 1 - c = g:sub(i, i) - if c == "" then - p = p .. "$" - break - elseif c == "?" then - p = p .. "." - elseif c == "*" then - p = p .. ".*" - elseif c == "[" then - if not charset() then - break - end - elseif c == "\\" then - i = i + 1 - c = g:sub(i, i) - if c == "" then - p = p .. "\\$" - break - end - p = p .. escape(c) - else - p = p .. escape(c) - end - end - return p + return p end return M diff --git a/lua/project_nvim/utils/history.lua b/lua/project_nvim/utils/history.lua index 486c1ee3..60cb357b 100644 --- a/lua/project_nvim/utils/history.lua +++ b/lua/project_nvim/utils/history.lua @@ -1,178 +1,235 @@ -local path = require("project_nvim.utils.path") -local uv = vim.loop -local M = {} -local is_windows = vim.fn.has('win32') or vim.fn.has('wsl') - -M.recent_projects = nil -- projects from previous neovim sessions -M.session_projects = {} -- projects from current neovim session -M.has_watch_setup = false - +---@alias OpenMode +---|integer +---|string +---|"a" +---|"a+" +---|"ax" +---|"ax+" +---|"r" +---|"r+" +---|"rs" +---|"rs" +---|"sr" +---|"sr+" +---|"w" +---|"w+" +---|"wx" +---|"wx+" +---|"xa" +---|"xa+" +---|"xw" +---|"xw+" + +---@class ProjParam +---@field value string + +---@class Project.Utils.History +---@field recent_projects (string|nil)[]|nil +---@field session_projects (string|nil)[]|nil +---@field has_watch_setup boolean +---@field read_projects_from_history fun() +---@field write_projects_to_history fun() +---@field get_recent_projects fun(): table|string[] +---@field delete_project fun(project: ProjParam) + +local path = require('project_nvim.utils.path') +local uv = vim.uv or vim.loop +local is_windows = (uv.os_uname().version:match('Windows') ~= nil or vim.fn.has('wsl')) -- Thanks to `folke` for +-- that code + +---@type Project.Utils.History +---@diagnostic disable-next-line:missing-fields +local M = { + -- projects from previous neovim sessions + recent_projects = nil, + + -- projects from current neovim session + session_projects = {}, + has_watch_setup = false, +} + +---@param mode OpenMode +---@param callback? fun(err: string|nil, fd: integer|nil) +---@return integer|nil local function open_history(mode, callback) - if callback ~= nil then -- async - path.create_scaffolding(function(_, _) - uv.fs_open(path.historyfile, mode, 438, callback) - end) - else -- sync - path.create_scaffolding() - return uv.fs_open(path.historyfile, mode, 438) - end + if callback ~= nil then -- async + path.create_scaffolding( + function(_, _) uv.fs_open(path.historyfile, mode, 438, callback) end + ) + else -- sync + path.create_scaffolding() + return uv.fs_open(path.historyfile, mode, 438) + end end +---@param dir string +---@return boolean local function dir_exists(dir) - local stat = uv.fs_stat(dir) - if stat ~= nil and stat.type == "directory" then - return true - end - return false + local stat = uv.fs_stat(dir) + + return (stat ~= nil and stat.type == 'directory') end +---@param path_to_normalise string +---@return string normalised_path local function normalise_path(path_to_normalise) - local normalised_path = path_to_normalise:gsub("\\", "/"):gsub("//", "/") + local normalised_path = path_to_normalise:gsub('\\', '/'):gsub('//', '/') if is_windows then - normalised_path = normalised_path:sub(1,1):lower()..normalised_path:sub(2) + normalised_path = normalised_path:sub(1, 1):lower() .. normalised_path:sub(2) end return normalised_path end +---@param tbl string[] +---@return string[] res local function delete_duplicates(tbl) - local cache_dict = {} - for _, v in ipairs(tbl) do - local normalised_path = normalise_path(v) - if cache_dict[normalised_path] == nil then - cache_dict[normalised_path] = 1 - else - cache_dict[normalised_path] = cache_dict[normalised_path] + 1 + ---@type table + local cache_dict = {} + for _, v in next, tbl do + local normalised_path = normalise_path(v) + if cache_dict[normalised_path] == nil then + cache_dict[normalised_path] = 1 + else + cache_dict[normalised_path] = cache_dict[normalised_path] + 1 + end end - end - local res = {} - for _, v in ipairs(tbl) do - local normalised_path = normalise_path(v) - if cache_dict[normalised_path] == 1 then - table.insert(res, normalised_path) - else - cache_dict[normalised_path] = cache_dict[normalised_path] - 1 + ---@type string[] + local res = {} + for _, v in next, tbl do + local normalised_path = normalise_path(v) + if cache_dict[normalised_path] == 1 then + table.insert(res, normalised_path) + else + cache_dict[normalised_path] = cache_dict[normalised_path] - 1 + end end - end - return res + return res end +---@param project ProjParam function M.delete_project(project) - for k, v in ipairs(M.recent_projects) do - if v == project.value then - M.recent_projects[k] = nil + for k, v in next, M.recent_projects do + if v == project.value then + M.recent_projects[k] = nil + end end - end end +---@param history_data string local function deserialize_history(history_data) - -- split data to table - local projects = {} - for s in history_data:gmatch("[^\r\n]+") do - if not path.is_excluded(s) and dir_exists(s) then - table.insert(projects, s) + -- split data to table + ---@type string[] + local projects = {} + for s in history_data:gmatch('[^\r\n]+') do + if not path.is_excluded(s) and dir_exists(s) then + table.insert(projects, s) + end end - end - projects = delete_duplicates(projects) + projects = delete_duplicates(projects) - M.recent_projects = projects + M.recent_projects = projects end local function setup_watch() - -- Only runs once - if M.has_watch_setup == false then - M.has_watch_setup = true - local event = uv.new_fs_event() - if event == nil then - return + -- Only runs once + if not M.has_watch_setup then + M.has_watch_setup = true + local event = uv.new_fs_event() + if event == nil then + return + end + event:start(path.projectpath, {}, function(err, _, events) + if err ~= nil then + return + end + if events.change then + M.recent_projects = nil + M.read_projects_from_history() + end + end) end - event:start(path.projectpath, {}, function(err, _, events) - if err ~= nil then - return - end - if events["change"] then - M.recent_projects = nil - M.read_projects_from_history() - end - end) - end end function M.read_projects_from_history() - open_history("r", function(_, fd) - setup_watch() - if fd ~= nil then - uv.fs_fstat(fd, function(_, stat) - if stat ~= nil then - uv.fs_read(fd, stat.size, -1, function(_, data) - uv.fs_close(fd, function(_, _) end) - deserialize_history(data) - end) + open_history('r', function(_, fd) + setup_watch() + if fd ~= nil then + uv.fs_fstat(fd, function(_, stat) + if stat ~= nil then + uv.fs_read(fd, stat.size, -1, function(_, data) + uv.fs_close(fd, function(_, _) end) + deserialize_history(data) + end) + end + end) end - end) - end - end) + end) end +---@return string[] real_tbl local function sanitize_projects() - local tbl = {} - if M.recent_projects ~= nil then - vim.list_extend(tbl, M.recent_projects) - vim.list_extend(tbl, M.session_projects) - else - tbl = M.session_projects - end - - tbl = delete_duplicates(tbl) - - local real_tbl = {} - for _, dir in ipairs(tbl) do - if dir_exists(dir) then - table.insert(real_tbl, dir) + ---@type string[] + local tbl = {} + + if M.recent_projects ~= nil then + vim.list_extend(tbl, M.recent_projects) + vim.list_extend(tbl, M.session_projects) + else + tbl = M.session_projects end - end - return real_tbl -end + tbl = delete_duplicates(tbl) + + ---@type string[] + local real_tbl = {} + for _, dir in next, tbl do + if dir_exists(dir) then + table.insert(real_tbl, dir) + end + end -function M.get_recent_projects() - return sanitize_projects() + return real_tbl end +---@return string[] +function M.get_recent_projects() return sanitize_projects() end + function M.write_projects_to_history() - -- Unlike read projects, write projects is synchronous - -- because it runs when vim ends - local mode = "w" - if M.recent_projects == nil then - mode = "a" - end - local file = open_history(mode) - - if file ~= nil then - local res = sanitize_projects() - - -- Trim table to last 100 entries - local len_res = #res - local tbl_out - if #res > 100 then - tbl_out = vim.list_slice(res, len_res - 100, len_res) - else - tbl_out = res + -- Unlike read projects, write projects is synchronous + -- because it runs when vim ends + local mode = 'w' + if M.recent_projects == nil then + mode = 'a' end + local file = open_history(mode) + + if file ~= nil then + local res = sanitize_projects() + + -- Trim table to last 100 entries + local len_res = #res + ---@type string[] + local tbl_out + if #res > 100 then + tbl_out = vim.list_slice(res, len_res - 100, len_res) + else + tbl_out = res + end - -- Transform table to string - local out = "" - for _, v in ipairs(tbl_out) do - out = out .. v .. "\n" - end + -- Transform table to string + local out = '' + for _, v in next, tbl_out do + out = out .. v .. '\n' + end - -- Write string out to file and close - uv.fs_write(file, out, -1) - uv.fs_close(file) - end + -- Write string out to file and close + uv.fs_write(file, out, -1) + uv.fs_close(file) + end end return M diff --git a/lua/project_nvim/utils/path.lua b/lua/project_nvim/utils/path.lua index 69ac7ade..aafbca74 100644 --- a/lua/project_nvim/utils/path.lua +++ b/lua/project_nvim/utils/path.lua @@ -1,37 +1,48 @@ -local config = require("project_nvim.config") -local uv = vim.loop +local config = require('project_nvim.config') +local uv = vim.uv or vim.loop + +---@class Project.Utils.Path +---@field datapath string +---@field projectpath string +---@field historyfile string +---@field init fun() +---@field create_scaffolding fun(callback: (fun(err: string|nil, success: boolean|nil))?) +---@field is_excluded fun(dir: string): boolean +---@field exists fun(path: string): boolean + +---@type Project.Utils.Path +---@diagnostic disable-next-line:missing-fields local M = {} -M.datapath = vim.fn.stdpath("data") -- directory -M.projectpath = M.datapath .. "/project_nvim" -- directory -M.historyfile = M.projectpath .. "/project_history" -- file +M.datapath = vim.fn.stdpath('data') -- directory +M.projectpath = M.datapath .. '/project_nvim' -- directory +M.historyfile = M.projectpath .. '/project_history' -- file function M.init() - M.datapath = require("project_nvim.config").options.datapath - M.projectpath = M.datapath .. "/project_nvim" -- directory - M.historyfile = M.projectpath .. "/project_history" -- file + M.datapath = config.options.datapath + M.projectpath = M.datapath .. '/project_nvim' -- directory + M.historyfile = M.projectpath .. '/project_history' -- file end +---@param callback fun(err: string|nil, success: boolean|nil) function M.create_scaffolding(callback) - if callback ~= nil then -- async - uv.fs_mkdir(M.projectpath, 448, callback) - else -- sync - uv.fs_mkdir(M.projectpath, 448) - end + uv.fs_mkdir(M.projectpath, 448, callback ~= nil and callback or nil) end +---@param dir string +---@return boolean function M.is_excluded(dir) - for _, dir_pattern in ipairs(config.options.exclude_dirs) do - if dir:match(dir_pattern) ~= nil then - return true + for _, dir_pattern in ipairs(config.options.exclude_dirs) do + if dir:match(dir_pattern) ~= nil then + return true + end end - end - return false + return false end -function M.exists(path) - return vim.fn.empty(vim.fn.glob(path)) == 0 -end +---@param path string +---@return boolean +function M.exists(path) return vim.fn.empty(vim.fn.glob(path)) == 0 end return M diff --git a/lua/telescope/_extensions/projects.lua b/lua/telescope/_extensions/projects.lua index be35f4f3..8bf92137 100644 --- a/lua/telescope/_extensions/projects.lua +++ b/lua/telescope/_extensions/projects.lua @@ -1,180 +1,191 @@ -- Inspiration from: -- https://github.com/nvim-telescope/telescope-project.nvim -local has_telescope, telescope = pcall(require, "telescope") +local has_telescope, telescope = pcall(require, 'telescope') if not has_telescope then - return + return end -local finders = require("telescope.finders") -local pickers = require("telescope.pickers") -local telescope_config = require("telescope.config").values -local actions = require("telescope.actions") -local state = require("telescope.actions.state") -local builtin = require("telescope.builtin") -local entry_display = require("telescope.pickers.entry_display") +local finders = require('telescope.finders') +local pickers = require('telescope.pickers') +local telescope_config = require('telescope.config').values +local actions = require('telescope.actions') +local state = require('telescope.actions.state') +local builtin = require('telescope.builtin') +local entry_display = require('telescope.pickers.entry_display') -local history = require("project_nvim.utils.history") -local project = require("project_nvim.project") -local config = require("project_nvim.config") +local history = require('project_nvim.utils.history') +local project = require('project_nvim.project') +local config = require('project_nvim.config') ---------- -- Actions ---------- +---@return table local function create_finder() - local results = history.get_recent_projects() - - -- Reverse results - for i = 1, math.floor(#results / 2) do - results[i], results[#results - i + 1] = results[#results - i + 1], results[i] - end - local displayer = entry_display.create({ - separator = " ", - items = { - { - width = 30, - }, - { - remaining = true, - }, - }, - }) - - local function make_display(entry) - return displayer({ entry.name, { entry.value, "Comment" } }) - end - - return finders.new_table({ - results = results, - entry_maker = function(entry) - local name = vim.fn.fnamemodify(entry, ":t") - return { - display = make_display, - name = name, - value = entry, - ordinal = name .. " " .. entry, - } - end, - }) + local results = history.get_recent_projects() + + -- Reverse results + for i = 1, math.floor(#results / 2) do + results[i], results[#results - i + 1] = results[#results - i + 1], results[i] + end + local displayer = entry_display.create({ + separator = ' ', + items = { + { + width = 30, + }, + { + remaining = true, + }, + }, + }) + + ---@param entry table + local function make_display(entry) return displayer({ entry.name, { entry.value, 'Comment' } }) end + + return finders.new_table({ + results = results, + entry_maker = function(entry) + local name = vim.fn.fnamemodify(entry, ':t') + return { + display = make_display, + name = name, + value = entry, + ordinal = name .. ' ' .. entry, + } + end, + }) end +---@param prompt_bufnr integer +---@param prompt boolean +---@return unknown|nil,boolean? local function change_working_directory(prompt_bufnr, prompt) - local selected_entry = state.get_selected_entry(prompt_bufnr) - if selected_entry == nil then + local selected_entry = state.get_selected_entry(prompt_bufnr) + if selected_entry == nil then + actions.close(prompt_bufnr) + return + end + local project_path = selected_entry.value + + -- FIXME: `_close()` is deprecated!!! (what did this conditional do beforehand????) actions.close(prompt_bufnr) - return - end - local project_path = selected_entry.value - if prompt == true then - actions._close(prompt_bufnr, true) - else - actions.close(prompt_bufnr) - end - local cd_successful = project.set_pwd(project_path, "telescope") - return project_path, cd_successful + local cd_successful = project.set_pwd(project_path, 'telescope') + return project_path, cd_successful end +---@param prompt_bufnr integer local function find_project_files(prompt_bufnr) - local project_path, cd_successful = change_working_directory(prompt_bufnr, true) - local opt = { - cwd = project_path, - hidden = config.options.show_hidden, - mode = "insert", - } - if cd_successful then - builtin.find_files(opt) - end + local project_path, cd_successful = change_working_directory(prompt_bufnr, true) + local opt = { + cwd = project_path, + hidden = config.options.show_hidden, + mode = 'insert', + } + if cd_successful then + builtin.find_files(opt) + end end +---@param prompt_bufnr integer local function browse_project_files(prompt_bufnr) - local project_path, cd_successful = change_working_directory(prompt_bufnr, true) - local opt = { - cwd = project_path, - hidden = config.options.show_hidden, - } - if cd_successful then - builtin.file_browser(opt) - end + local project_path, cd_successful = change_working_directory(prompt_bufnr, true) + local opt = { + cwd = project_path, + hidden = config.options.show_hidden, + } + if cd_successful then + builtin.file_browser(opt) + end end +---@param prompt_bufnr integer local function search_in_project_files(prompt_bufnr) - local project_path, cd_successful = change_working_directory(prompt_bufnr, true) - local opt = { - cwd = project_path, - hidden = config.options.show_hidden, - mode = "insert", - } - if cd_successful then - builtin.live_grep(opt) - end + local project_path, cd_successful = change_working_directory(prompt_bufnr, true) + local opt = { + cwd = project_path, + hidden = config.options.show_hidden, + mode = 'insert', + } + if cd_successful then + builtin.live_grep(opt) + end end +---@param prompt_bufnr integer local function recent_project_files(prompt_bufnr) - local _, cd_successful = change_working_directory(prompt_bufnr, true) - local opt = { - cwd_only = true, - hidden = config.options.show_hidden, - } - if cd_successful then - builtin.oldfiles(opt) - end + local _, cd_successful = change_working_directory(prompt_bufnr, true) + local opt = { + cwd_only = true, + hidden = config.options.show_hidden, + } + if cd_successful then + builtin.oldfiles(opt) + end end +---@param prompt_bufnr integer local function delete_project(prompt_bufnr) - local selectedEntry = state.get_selected_entry(prompt_bufnr) - if selectedEntry == nil then - actions.close(prompt_bufnr) - return - end - local choice = vim.fn.confirm("Delete '" .. selectedEntry.value .. "' from project list?", "&Yes\n&No", 2) + local selectedEntry = state.get_selected_entry(prompt_bufnr) + if selectedEntry == nil then + actions.close(prompt_bufnr) + return + end + + local choice = vim.fn.confirm( + string.format("Delete '%s' from project list?", selectedEntry.value), + '&Yes\n&No', + 2 + ) + + if choice ~= 1 then + return + end - if choice == 1 then history.delete_project(selectedEntry) local finder = create_finder() state.get_current_picker(prompt_bufnr):refresh(finder, { - reset_prompt = true, + reset_prompt = true, }) - end end ---Main entrypoint for Telescope. ---@param opts table local function projects(opts) - opts = opts or {} - - pickers.new(opts, { - prompt_title = "Recent Projects", - finder = create_finder(), - previewer = false, - sorter = telescope_config.generic_sorter(opts), - attach_mappings = function(prompt_bufnr, map) - map("n", "f", find_project_files) - map("n", "b", browse_project_files) - map("n", "d", delete_project) - map("n", "s", search_in_project_files) - map("n", "r", recent_project_files) - map("n", "w", change_working_directory) - - map("i", "", find_project_files) - map("i", "", browse_project_files) - map("i", "", delete_project) - map("i", "", search_in_project_files) - map("i", "", recent_project_files) - map("i", "", change_working_directory) - - local on_project_selected = function() - find_project_files(prompt_bufnr) - end - actions.select_default:replace(on_project_selected) - return true - end, - }):find() + opts = opts or {} + + pickers + .new(opts, { + prompt_title = 'Recent Projects', + finder = create_finder(), + previewer = false, + sorter = telescope_config.generic_sorter(opts), + ---@param prompt_bufnr integer + ---@param map fun(mode: string, lhs: string, rhs: string|fun()) + attach_mappings = function(prompt_bufnr, map) + map('n', 'f', find_project_files) + map('n', 'b', browse_project_files) + map('n', 'd', delete_project) + map('n', 's', search_in_project_files) + map('n', 'r', recent_project_files) + map('n', 'w', change_working_directory) + + map('i', '', find_project_files) + map('i', '', browse_project_files) + map('i', '', delete_project) + map('i', '', search_in_project_files) + map('i', '', recent_project_files) + map('i', '', change_working_directory) + + local on_project_selected = function() find_project_files(prompt_bufnr) end + actions.select_default:replace(on_project_selected) + return true + end, + }) + :find() end -return telescope.register_extension({ - exports = { - projects = projects, - }, -}) +return telescope.register_extension({ exports = { projects = projects } })