Skip to content

Commit 06266e7

Browse files
author
Jose Alvarez
authored
feat: scandir improvements (better respect_gitignore + only_dirs) (#169)
1 parent d6864ff commit 06266e7

2 files changed

Lines changed: 146 additions & 29 deletions

File tree

lua/plenary/scandir.lua

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,34 @@ local uv = vim.loop
66

77
local m = {}
88

9-
local get_gitignore = function(basepath)
10-
local gitignore = {}
9+
local make_gitignore = function(basepath)
10+
local patterns = {}
1111
local valid = false
1212
for _, v in ipairs(basepath) do
1313
local p = Path:new(v .. os_sep .. ".gitignore")
1414
if p:exists() then
1515
valid = true
16-
gitignore[v] = {}
16+
patterns[v] = { ignored = {}, negated = {} }
1717
for l in p:iter() do
18-
if l ~= "" then
19-
local el = l:gsub("%#.*", "")
18+
local prefix = l:sub(1, 1)
19+
local negated = prefix == "!"
20+
if negated then
21+
l = l:sub(2)
22+
prefix = l:sub(1, 1)
23+
end
24+
if prefix == "/" then
25+
l = v .. l
26+
end
27+
if not (prefix == "" or prefix == "#") then
28+
local el = vim.trim(l)
29+
el = el:gsub("%-", "%%-")
2030
el = el:gsub("%.", "%%.")
21-
el = el:gsub("%*", "%.%*")
31+
el = el:gsub("/%*%*/", "/%%w+/")
32+
el = el:gsub("%*%*", "")
33+
el = el:gsub("%*", "%%w+")
34+
el = el:gsub("%?", "%%w")
2235
if el ~= "" then
23-
table.insert(gitignore[v], el)
36+
table.insert(negated and patterns[v].negated or patterns[v].ignored, el)
2437
end
2538
end
2639
end
@@ -29,21 +42,29 @@ local get_gitignore = function(basepath)
2942
if not valid then
3043
return nil
3144
end
32-
return gitignore
33-
end
34-
35-
local interpret_gitignore = function(gitignore, bp, entry)
36-
for _, v in ipairs(bp) do
37-
if entry:find(v, 1, true) then
38-
for _, w in ipairs(gitignore[v]) do
39-
if entry:match(w) then
40-
return false
45+
return function(bp, entry)
46+
for _, v in ipairs(bp) do
47+
if entry:find(v, 1, true) then
48+
local negated = false
49+
for _, w in ipairs(patterns[v].ignored) do
50+
if not negated and entry:match(w) then
51+
for _, inverse in ipairs(patterns[v].negated) do
52+
if not negated and entry:match(inverse) then
53+
negated = true
54+
end
55+
end
56+
if not negated then
57+
return false
58+
end
59+
end
4160
end
4261
end
4362
end
63+
return true
4464
end
45-
return true
4665
end
66+
-- exposed for testing
67+
m.__make_gitignore = make_gitignore
4768

4869
local handle_depth = function(base_paths, entry, depth)
4970
for _, v in ipairs(base_paths) do
@@ -73,6 +94,8 @@ local gen_search_pat = function(pattern)
7394
end
7495
return false
7596
end
97+
elseif type(pattern) == "function" then
98+
return pattern
7699
end
77100
end
78101

@@ -85,8 +108,8 @@ local process_item = function(opts, name, typ, current_dir, next_dir, bp, data,
85108
else
86109
table.insert(next_dir, entry)
87110
end
88-
if opts.add_dirs then
89-
if not giti or interpret_gitignore(giti, bp, entry .. "/") then
111+
if opts.add_dirs or opts.only_dirs then
112+
if not giti or giti(bp, entry .. "/") then
90113
if not msp or msp(entry) then
91114
table.insert(data, entry)
92115
if opts.on_insert then
@@ -95,9 +118,9 @@ local process_item = function(opts, name, typ, current_dir, next_dir, bp, data,
95118
end
96119
end
97120
end
98-
else
121+
elseif not opts.only_dirs then
99122
local entry = current_dir .. os_sep .. name
100-
if not giti or interpret_gitignore(giti, bp, entry) then
123+
if not giti or giti(bp, entry) then
101124
if not msp or msp(entry) then
102125
table.insert(data, entry)
103126
if opts.on_insert then
@@ -117,9 +140,10 @@ end
117140
-- @param opts: table to change behavior
118141
-- opts.hidden (bool): if true hidden files will be added
119142
-- opts.add_dirs (bool): if true dirs will also be added to the results
143+
-- opts.only_dirs (bool): if true only dirs will be added to the results
120144
-- opts.respect_gitignore (bool): if true will only add files that are not ignored by the git (uses each gitignore found in path table)
121145
-- opts.depth (int): depth on how deep the search should go
122-
-- opts.search_pattern (regex): regex for which files will be added, string or table of strings
146+
-- opts.search_pattern (regex): regex for which files will be added, string, table of strings, or callback (should return bool)
123147
-- opts.on_insert(entry): Will be called for each element
124148
-- opts.silent (bool): if true will not echo messages that are not accessible
125149
-- @return array with files
@@ -130,8 +154,8 @@ m.scan_dir = function(path, opts)
130154
local base_paths = vim.tbl_flatten { path }
131155
local next_dir = vim.tbl_flatten { path }
132156

133-
local gitignore = opts.respect_gitignore and get_gitignore(base_paths) or nil
134-
local match_seach_pat = opts.search_pattern and gen_search_pat(opts.search_pattern) or nil
157+
local gitignore = opts.respect_gitignore and make_gitignore(base_paths) or nil
158+
local match_search_pat = opts.search_pattern and gen_search_pat(opts.search_pattern) or nil
135159

136160
for i = table.getn(base_paths), 1, -1 do
137161
if uv.fs_access(base_paths[i], "X") == false then
@@ -154,7 +178,7 @@ m.scan_dir = function(path, opts)
154178
if name == nil then
155179
break
156180
end
157-
process_item(opts, name, typ, current_dir, next_dir, base_paths, data, gitignore, match_seach_pat)
181+
process_item(opts, name, typ, current_dir, next_dir, base_paths, data, gitignore, match_search_pat)
158182
end
159183
end
160184
until table.getn(next_dir) == 0
@@ -169,9 +193,10 @@ end
169193
-- @param opts: table to change behavior
170194
-- opts.hidden (bool): if true hidden files will be added
171195
-- opts.add_dirs (bool): if true dirs will also be added to the results
196+
-- opts.only_dirs (bool): if true only dirs will be added to the results
172197
-- opts.respect_gitignore (bool): if true will only add files that are not ignored by git
173198
-- opts.depth (int): depth on how deep the search should go
174-
-- opts.search_pattern (lua regex): depth on how deep the search should go
199+
-- opts.search_pattern (regex): regex for which files will be added, string, table of strings, or callback (should return bool)
175200
-- opts.on_insert function(entry): will be called for each element
176201
-- opts.on_exit function(results): will be called at the end
177202
-- opts.silent (bool): if true will not echo messages that are not accessible
@@ -184,8 +209,8 @@ m.scan_dir_async = function(path, opts)
184209
local current_dir = table.remove(next_dir, 1)
185210

186211
-- TODO(conni2461): get gitignore is not async
187-
local gitignore = opts.respect_gitignore and get_gitignore() or nil
188-
local match_seach_pat = opts.search_pattern and gen_search_pat(opts.search_pattern) or nil
212+
local gitignore = opts.respect_gitignore and make_gitignore(base_paths) or nil
213+
local match_search_pat = opts.search_pattern and gen_search_pat(opts.search_pattern) or nil
189214

190215
-- TODO(conni2461): is not async. Shouldn't be that big of a problem but still
191216
-- Maybe obers async pr can take me out of callback hell
@@ -209,7 +234,7 @@ m.scan_dir_async = function(path, opts)
209234
if name == nil then
210235
break
211236
end
212-
process_item(opts, name, typ, current_dir, next_dir, base_paths, data, gitignore, match_seach_pat)
237+
process_item(opts, name, typ, current_dir, next_dir, base_paths, data, gitignore, match_search_pat)
213238
end
214239
if table.getn(next_dir) == 0 then
215240
if opts.on_exit then

tests/plenary/scandir_spec.lua

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
local scan = require "plenary.scandir"
2+
local mock = require "luassert.mock"
3+
local stub = require "luassert.stub"
24
local eq = assert.are.same
35

46
local contains = function(tbl, str)
@@ -73,6 +75,16 @@ describe("scandir", function()
7375
eq(false, contains(dirs, "./asdf/asdf/adsf.lua"))
7476
end)
7577

78+
it("with only directories", function()
79+
local dirs = scan.scan_dir(".", { only_dirs = true })
80+
eq("table", type(dirs))
81+
eq(false, contains(dirs, "./CHANGELOG.md"))
82+
eq(false, contains(dirs, "./lua/plenary/job.lua"))
83+
eq(true, contains(dirs, "./lua"))
84+
eq(true, contains(dirs, "./tests"))
85+
eq(false, contains(dirs, "./asdf/asdf/adsf.lua"))
86+
end)
87+
7688
it("until depth 1 is reached", function()
7789
local dirs = scan.scan_dir(".", { depth = 1 })
7890
eq("table", type(dirs))
@@ -127,6 +139,86 @@ describe("scandir", function()
127139
eq(true, contains(dirs, "./data/plenary/filetypes/builtin.lua"))
128140
eq(false, contains(dirs, "./README.md"))
129141
end)
142+
143+
it("with callback search pattern", function()
144+
local dirs = scan.scan_dir(".", {
145+
search_pattern = function(entry)
146+
return entry:match "filetype"
147+
end,
148+
})
149+
eq("table", type(dirs))
150+
eq(true, contains(dirs, "./scripts/update_filetypes_from_github.lua"))
151+
eq(true, contains(dirs, "./lua/plenary/filetype.lua"))
152+
eq(true, contains(dirs, "./tests/plenary/filetype_spec.lua"))
153+
eq(true, contains(dirs, "./data/plenary/filetypes/base.lua"))
154+
eq(true, contains(dirs, "./data/plenary/filetypes/builtin.lua"))
155+
eq(false, contains(dirs, "./README.md"))
156+
end)
157+
end)
158+
159+
describe("gitignore", function()
160+
local Path = require "plenary.path"
161+
local mock_path, mock_gitignore
162+
before_each(function()
163+
mock_path = {
164+
exists = stub.new().returns(true),
165+
iter = function()
166+
local i = 0
167+
local n = table.getn(mock_gitignore)
168+
return function()
169+
i = i + 1
170+
if i <= n then
171+
return mock_gitignore[i]
172+
end
173+
end
174+
end,
175+
}
176+
Path.new = stub.new().returns(mock_path)
177+
end)
178+
after_each(function()
179+
Path.new:revert()
180+
end)
181+
182+
describe("ignores path", function()
183+
it("when path matches pattern exactly", function()
184+
mock_gitignore = { "ignored.txt" }
185+
local should_add = scan.__make_gitignore { "path" }
186+
eq(false, should_add({ "path" }, "./path/ignored.txt"))
187+
end)
188+
it("when path matches * pattern", function()
189+
mock_gitignore = { "*.txt" }
190+
local should_add = scan.__make_gitignore { "path" }
191+
eq(false, should_add({ "path" }, "./path/dir/ignored.txt"))
192+
end)
193+
it("when path matches leading ** pattern", function()
194+
mock_gitignore = { "**/ignored.txt" }
195+
local should_add = scan.__make_gitignore { "path" }
196+
eq(false, should_add({ "path" }, "./path/dir/subdir/ignored.txt"))
197+
end)
198+
it("when path matches trailing ** pattern", function()
199+
mock_gitignore = { "/dir/**" }
200+
local should_add = scan.__make_gitignore { "path" }
201+
eq(false, should_add({ "path" }, "./path/dir/subdir/ignored.txt"))
202+
end)
203+
it("when path matches ? pattern", function()
204+
mock_gitignore = { "ignore?.txt" }
205+
local should_add = scan.__make_gitignore { "path" }
206+
eq(false, should_add({ "path" }, "./path/ignored.txt"))
207+
end)
208+
end)
209+
210+
describe("does not ignore path", function()
211+
it("when path does not match", function()
212+
mock_gitignore = { "ignored.txt" }
213+
local should_add = scan.__make_gitignore { "path" }
214+
eq(true, should_add({ "path" }, "./path/ok.txt"))
215+
end)
216+
it("when path is negated", function()
217+
mock_gitignore = { "*.txt", "!ok.txt" }
218+
local should_add = scan.__make_gitignore { "path" }
219+
eq(true, should_add({ "path" }, "./path/ok.txt"))
220+
end)
221+
end)
130222
end)
131223

132224
describe("ls", function()

0 commit comments

Comments
 (0)