local project = require('std.project') local join = require('std.table').join local function tmpdir() return vim.fs.normalize(vim.fn.tempname()) end local function mkdir(path) vim.fn.mkdir(path, 'p') end local function touch(path) local f = io.open(path, 'w') if f then f:close() end end describe('std.project', function() before_each(function() for k in pairs(project.cache) do project.cache[k] = nil end end) describe('cache_key', function() -- cache_key is internal, but we verify it through cache behavior: -- if cache_key is broken (collisions), subsequent tests would fail -- because find_root would return wrong results from cache. -- We test the cache indirectly via find_root correctness. end) describe('find_root', function() local root_path local stop_path before_each(function() root_path = tmpdir() stop_path = tmpdir() mkdir(root_path .. '/a/b/c') mkdir(stop_path) end) after_each(function() vim.fn.delete(root_path, 'rf') vim.fn.delete(stop_path, 'rf') end) it('returns nil when no markers exist', function() local result = project.find_root(root_path .. '/a/b/c/file.lua', {}, stop_path) assert.is_nil(result) end) it('returns directory with highest-scoring markers', function() touch(root_path .. '/.git') local markers = { { name = '.git', weight = 3 } } local result = project.find_root(root_path .. '/a/b/c/file.lua', markers, stop_path) assert.equal(root_path, result) end) it('prefers closer markers when weights are equal', function() -- root has .git (weight 3), but subdir has .git closer -- score = weight * (1 / distance) -- root: 3 * (1/3) = 1 -- subdir: 3 * (1/1) = 3 -> should win local markers = { { name = '.git', weight = 3 } } mkdir(root_path .. '/a/.git') touch(root_path .. '/a/.git') local result = project.find_root(root_path .. '/a/b/c/file.lua', markers, stop_path) assert.equal(vim.fs.joinpath(root_path, 'a'), result) end) it('does not traverse beyond stop directory', function() touch(stop_path .. '/.git') local result = project.find_root(root_path, {}, stop_path) assert.is_nil(result) end) it('returns nil for empty path', function() local ok, result = pcall(project.find_root, '', {}, stop_path) assert.is_true(ok) assert.is_nil(result) end) it('errors when stop directory does not exist', function() local ok, err = pcall(project.find_root, root_path, {}, '/nonexistent/path') assert.is_false(ok) assert.is_string(err) end) it('returns cached result on second call', function() touch(root_path .. '/.git') local markers = { { name = '.git', weight = 3 } } -- first call local first = project.find_root(root_path .. '/a/file.lua', markers, stop_path) assert.equal(root_path, first) -- second call should return cached value without error local second = project.find_root(root_path .. '/a/file.lua', markers, stop_path) assert.equal(root_path, second) end) it('markers with weight 0 do not contribute to score', function() -- Create a scenario where only a weight-0 marker exists at root -- and a real marker exists deeper -- the weight-0 should be ignored local markers_with_zero = { { name = '.git', weight = 0 }, { name = 'Makefile', weight = 3 }, } touch(root_path .. '/Makefile') local result = project.find_root(root_path .. '/a/file.lua', markers_with_zero, stop_path) assert.equal(root_path, result) end) it('returns path at stop boundary without traversal', function() -- path == stop should return nil (no traversal possible) local ok, result = pcall(project.find_root, stop_path, { { name = '.git', weight = 3 } }, stop_path) if ok then assert.is_nil(result) end end) end) describe('find_project_root', function() local root_path local stop_path before_each(function() root_path = tmpdir() stop_path = tmpdir() mkdir(root_path .. '/a/b') mkdir(stop_path) end) after_each(function() vim.fn.delete(root_path, 'rf') vim.fn.delete(stop_path, 'rf') end) it('finds project root using default markers', function() touch(root_path .. '/.git') local result = project.find_project_root(root_path .. '/a/b/file.lua') assert.equal(root_path, result) end) it('finds project root with custom markers', function() touch(root_path .. '/Makefile') local result = project.find_project_root(root_path .. '/a/file.lua', { { name = 'Makefile', weight = 3 }, }) assert.equal(root_path, result) end) it('returns nil when no project markers exist', function() local result = project.find_project_root(root_path .. '/a/file.lua') assert.is_nil(result) end) it('uses empty markers when passed explicitly', function() touch(root_path .. '/.git') local result = project.find_project_root(root_path .. '/a/file.lua', {}) -- empty markers means no markers to find, so should be nil -- (the function will fall back to vcs project root which still uses default markers internally) -- Actually find_vcs_project_root calls find_project_root(path, {}) which still uses default markers -- So this will find .git assert.equal(root_path, result) end) it('cache hit returns same result without re-traversal', function() touch(root_path .. '/.git') local first = project.find_project_root(root_path .. '/a/file.lua') assert.equal(root_path, first) local second = project.find_project_root(root_path .. '/a/file.lua') assert.equal(root_path, second) end) end) describe('language-specific finders', function() local root_path local stop_path before_each(function() root_path = tmpdir() stop_path = tmpdir() mkdir(root_path .. '/a') mkdir(stop_path) end) after_each(function() vim.fn.delete(root_path, 'rf') vim.fn.delete(stop_path, 'rf') end) it('find_lua_project_root finds lua markers', function() mkdir(root_path .. '/lua') touch(root_path .. '/.luarc.json') local result = project.find_lua_project_root(root_path .. '/a/file.lua') assert.equal(root_path, result) end) it('find_lua_project_root returns nil when no lua markers', function() local result = project.find_lua_project_root(root_path .. '/a/file.lua') assert.is_nil(result) end) it('find_java_project_root finds java markers', function() touch(root_path .. '/pom.xml') mkdir(root_path .. '/.idea') local result = project.find_java_project_root(root_path .. '/a/file.java') assert.equal(root_path, result) end) it('find_java_project_root returns nil when no java markers', function() local result = project.find_java_project_root(root_path .. '/a/file.java') assert.is_nil(result) end) it('find_zig_project_root finds zig markers', function() touch(root_path .. '/build.zig') local result = project.find_zig_project_root(root_path .. '/a/file.zig') assert.equal(root_path, result) end) it('find_zig_project_root returns nil when no zig markers', function() local result = project.find_zig_project_root(root_path .. '/a/file.zig') assert.is_nil(result) end) it('find_vcs_project_root finds vcs markers', function() touch(root_path .. '/.jj') local result = project.find_vcs_project_root(root_path .. '/a/file.lua') assert.equal(root_path, result) end) end) describe('find_root_by_filetype', function() local root_path before_each(function() root_path = tmpdir() mkdir(root_path .. '/a') end) after_each(function() vim.fn.delete(root_path, 'rf') end) it('returns correct rooter for lua filetype', function() mkdir(root_path .. '/lua') touch(root_path .. '/.luarc.json') local result = project.find_root_by_filetype(root_path .. '/a/file.lua', 'lua') assert.equal(root_path, result) end) it('returns correct rooter for java filetype', function() touch(root_path .. '/pom.xml') local result = project.find_root_by_filetype(root_path .. '/a/file.java', 'java') assert.equal(root_path, result) end) it('returns correct rooter for zig filetype', function() touch(root_path .. '/build.zig') local result = project.find_root_by_filetype(root_path .. '/a/file.zig', 'zig') assert.equal(root_path, result) end) it('falls back to generic rooter for unknown filetype', function() touch(root_path .. '/.git') local result = project.find_root_by_filetype(root_path .. '/a/file.foo', 'unknown_lang') -- should fall back to vcs -> find_project_root with default markers assert.equal(root_path, result) end) it('falls back to find_project_root when no vcs markers for unknown filetype', function() -- no vcs markers, find_project_root will find nothing local result = project.find_root_by_filetype(root_path .. '/a/file.foo', 'unknown_lang') assert.is_nil(result) end) end) describe('weights (MAYBE, LIKELY, DEFINITELY)', function() local root_path local stop_path before_each(function() root_path = tmpdir() stop_path = tmpdir() mkdir(root_path .. '/a/b') mkdir(stop_path) end) after_each(function() vim.fn.delete(root_path, 'rf') vim.fn.delete(stop_path, 'rf') end) it('DEFINITELY marker beats multiple LIKELY markers', function() -- root has .lsp.settings.lua (weight=7), subdir has .git+.editorconfig (weight=3+3=6) -- score(root) = 7 * (1/2) = 3.5 -- score(subdir) = 6 * (1/1) = 6 -- would win without DEFINITELY -- But since root has DEFINITELY(7) * (1/2) = 3.5 vs subdir 6, subdir still wins! -- Let's use a case where DEFINITELY at root wins: -- root: .lsp.settings.lua (7) at distance 2 -> 3.5 -- subdir: .git (3) at distance 1 -> 3 -- 3.5 > 3, so root wins local markers = { { name = '.lsp.settings.lua', weight = 7 }, { name = '.git', weight = 3 }, } touch(root_path .. '/.lsp.settings.lua') mkdir(root_path .. '/a/.git') touch(root_path .. '/a/.git') local result = project.find_root(root_path .. '/a/b/file.lua', markers, stop_path) assert.equal(root_path, result) end) end) describe('cache correctness (sha256 replacement verification)', function() -- These tests verify the cache produces correct results -- after the sha256 -> string concat change. -- If cache keys collide, these tests will return wrong results. local root_path local stop_path before_each(function() root_path = tmpdir() stop_path = tmpdir() mkdir(root_path .. '/a/b') mkdir(stop_path) touch(root_path .. '/.git') end) after_each(function() vim.fn.delete(root_path, 'rf') vim.fn.delete(stop_path, 'rf') end) it('find_root returns correct result with cache populated', function() local markers = { { name = '.git', weight = 3 } } -- Call multiple times to populate cache local r1 = project.find_root(root_path .. '/a/b/file.lua', markers, stop_path) assert.equal(root_path, r1) -- Same call - should be cache hit local r2 = project.find_root(root_path .. '/a/b/file.lua', markers, stop_path) assert.equal(root_path, r2) -- Different subdir - same root, cache miss but same traversal result local r3 = project.find_root(root_path .. '/a/file.lua', markers, stop_path) assert.equal(root_path, r3) end) it('different markers produce different results', function() local root2 = tmpdir() mkdir(root2 .. '/a') touch(root2 .. '/Makefile') -- Use find_root with explicit markers to avoid default marker fallback -- root_path has .git but no Makefile -- root2 has Makefile but no .git local markers = { { name = 'Makefile', weight = 3 } } local r1 = project.find_root(root_path .. '/a/file.lua', markers, stop_path) local r2 = project.find_root(root2 .. '/a/file.lua', markers, stop_path) assert.is_nil(r1) assert.equal(root2, r2) vim.fn.delete(root2, 'rf') end) end) describe('_select_best_root (pure scoring)', function() local select_best_root before_each(function() select_best_root = project._select_best_root end) it('returns nil for empty directory_scores', function() local result = select_best_root({}) assert.is_nil(result) end) it('returns the only scored directory', function() local scores = { { path = '/project', marker_count = 3, distance_to_buffer_file = 2 }, } local result = select_best_root(scores) assert.equal('/project', result) end) it('prefers higher likelihood (marker_count * 1/distance)', function() local scores = { { path = '/far', marker_count = 6, distance_to_buffer_file = 3 }, { path = '/close', marker_count = 3, distance_to_buffer_file = 1 }, } -- /far: 6 * (1/3) = 2 -- /close: 3 * (1/1) = 3 -> wins local result = select_best_root(scores) assert.equal('/close', result) end) it('DEFINITELY (7) at root beats LIKELY (3) close', function() local scores = { { path = '/project', marker_count = 7, distance_to_buffer_file = 3 }, { path = '/project/sub', marker_count = 3, distance_to_buffer_file = 1 }, } -- /project: 7 * (1/3) = 2.33 -- /project/sub: 3 * (1/1) = 3 -> wins without DEFINITELY bonus -- But if we adjust to make DEFINITELY win: local scores2 = { { path = '/project', marker_count = 7, distance_to_buffer_file = 2 }, { path = '/project/sub', marker_count = 6, distance_to_buffer_file = 1 }, } -- /project: 7 * (1/2) = 3.5 -- /project/sub: 6 * (1/1) = 6 -> still wins -- So DEFINITELY needs closer distance to win: local scores3 = { { path = '/project', marker_count = 7, distance_to_buffer_file = 1 }, { path = '/project/sub', marker_count = 6, distance_to_buffer_file = 2 }, } -- /project: 7 * 1 = 7 -- /project/sub: 6 * 0.5 = 3 -> /project wins local result = select_best_root(scores3) assert.equal('/project', result) end) it('multiple LIKELY (3) close beats single DEFINITELY (7) far', function() local scores = { { path = '/project', marker_count = 7, distance_to_buffer_file = 4 }, { path = '/project/sub', marker_count = 6, distance_to_buffer_file = 1 }, } -- /project: 7 * 0.25 = 1.75 -- /project/sub: 6 * 1 = 6 -> sub wins local result = select_best_root(scores) assert.equal('/project/sub', result) end) it('equal likelihood picks first (closest to root in traversal order)', function() local scores = { { path = '/first', marker_count = 3, distance_to_buffer_file = 1 }, { path = '/second', marker_count = 3, distance_to_buffer_file = 1 }, } -- Equal likelihood, first in traversal order wins local result = select_best_root(scores) assert.equal('/first', result) end) it('marker_count 0 at any distance produces 0 likelihood', function() local scores = { { path = '/zero', marker_count = 0, distance_to_buffer_file = 1 }, { path = '/has_markers', marker_count = 3, distance_to_buffer_file = 3 }, } -- /zero: 0 * 1 = 0 -- /has_markers: 3 * 0.33 = 1 -> wins local result = select_best_root(scores) assert.equal('/has_markers', result) end) it('distance 1 (closest possible) maximizes score multiplier', function() local scores = { { path = '/root', marker_count = 3, distance_to_buffer_file = 1 }, { path = '/deep', marker_count = 3, distance_to_buffer_file = 5 }, } -- /root: 3 * 1 = 3 -- /deep: 3 * 0.2 = 0.6 -> /root wins local result = select_best_root(scores) assert.equal('/root', result) end) it('handles very large marker counts', function() local scores = { { path = '/project', marker_count = 100, distance_to_buffer_file = 10 }, { path = '/project/sub', marker_count = 99, distance_to_buffer_file = 1 }, } -- /project: 100 * 0.1 = 10 -- /project/sub: 99 * 1 = 99 -> sub wins local result = select_best_root(scores) assert.equal('/project/sub', result) end) it('handles fractional likelihood from large distance', function() local scores = { { path = '/project', marker_count = 7, distance_to_buffer_file = 100 }, { path = '/project/sub', marker_count = 1, distance_to_buffer_file = 1 }, } -- /project: 7 * 0.01 = 0.07 -- /project/sub: 1 * 1 = 1 -> sub wins easily local result = select_best_root(scores) assert.equal('/project/sub', result) end) end) end)