diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..e251fe9 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,29 @@ +name: PR + +on: + pull_request: + branches: [ master ] + +jobs: + checks: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Lua + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.4" + + - name: Setup LuaRocks + uses: leafo/gh-actions-luarocks@v4 + + - name: Install dependencies + run: luarocks install --deps-only gh-co.nvim-0.0.5-1.rockspec + + - name: Lint + run: PATH="./lua_modules/bin:$PATH" luacheck lua + + - name: Test + run: luarocks test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d14386a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/luarocks +/lua_modules +/.luarocks diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..0259f39 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,9 @@ +std = "luajit" +globals = {"vim"} + +ignore = { + -- unused loop variable + "213", + -- ignore unused test functions + "[Tt]est[%w_]+", +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d36247..261603d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ +## v0.0.5 + +- add luarocks & unit tests +- fix incompatibility with CODEOWNERS spec, the plugin can now work with various patterns, can handle paths marked with no owner(s) or wildcard patterns +- fix highlighting, add treesitter support + ## v0.0.4 + - syntax highlighting for `CODEOWNERS` file ## v0.0.3 diff --git a/gh-co.nvim-0.0.4-1.rockspec b/gh-co.nvim-0.0.4-1.rockspec new file mode 100644 index 0000000..f0aaec9 --- /dev/null +++ b/gh-co.nvim-0.0.4-1.rockspec @@ -0,0 +1,33 @@ +rockspec_format = "3.0" +package = "gh-co.nvim" +version = "0.0.4-1" +source = { + url = "git+ssh://git@github.com/comatory/gh-co.nvim.git" +} +description = { + summary = "Github CODEOWNERS Neovim plugin", + detailed = "Displays the code owners for current buffer, all opened buffers or lists owners by providing git SHAs", + homepage = "https://github.com/comatory/gh-co.nvim", + license = "CC0 1.0 Universal" +} +dependencies = { + "lua >= 5.1", + "luacheck", +} +test_dependencies = { + "luaunit >= 3.4" +} +build = { + type = "builtin", + modules = { + ["gh-co.co"] = "lua/gh-co/co.lua", + ["gh-co.fs"] = "lua/gh-co/fs.lua", + ["gh-co.git"] = "lua/gh-co/git.lua", + ["gh-co.init"] = "lua/gh-co/init.lua", + ["gh-co.syntax"] = "lua/gh-co/syntax.lua" + } +} +test = { + type = "command", + command = "lua -l lua/setup lua/gh-co/co.test.lua -o TAP" +} diff --git a/gh-co.nvim-0.0.5-1.rockspec b/gh-co.nvim-0.0.5-1.rockspec new file mode 100644 index 0000000..0edc1cd --- /dev/null +++ b/gh-co.nvim-0.0.5-1.rockspec @@ -0,0 +1,33 @@ +rockspec_format = "3.0" +package = "gh-co.nvim" +version = "0.0.5-1" +source = { + url = "git+ssh://git@github.com/comatory/gh-co.nvim.git" +} +description = { + summary = "Github CODEOWNERS Neovim plugin", + detailed = "Displays the code owners for current buffer, all opened buffers or lists owners by providing git SHAs", + homepage = "https://github.com/comatory/gh-co.nvim", + license = "CC0 1.0 Universal" +} +dependencies = { + "lua >= 5.1", + "luacheck", +} +test_dependencies = { + "luaunit >= 3.4" +} +build = { + type = "builtin", + modules = { + ["gh-co.co"] = "lua/gh-co/co.lua", + ["gh-co.fs"] = "lua/gh-co/fs.lua", + ["gh-co.git"] = "lua/gh-co/git.lua", + ["gh-co.init"] = "lua/gh-co/init.lua", + ["gh-co.syntax"] = "lua/gh-co/syntax.lua" + } +} +test = { + type = "command", + command = "lua -l lua/setup lua/gh-co/co.test.lua -o TAP" +} diff --git a/lua/gh-co/co.lua b/lua/gh-co/co.lua index 52ddb84..8e87de9 100644 --- a/lua/gh-co/co.lua +++ b/lua/gh-co/co.lua @@ -7,7 +7,35 @@ local function isComment(pathPattern) end local function buildEscapedPattern(rawPattern) - return string.gsub(rawPattern, "%-", "%%-") + -- Escape Lua pattern special characters except * + local escaped = string.gsub(rawPattern, "([%-%+%?%(%)])", "%%%1") + + -- Handle ** first (before single *) - use placeholder to avoid conflicts + escaped = string.gsub(escaped, "%*%*", "__DOUBLESTAR__") + + -- Convert remaining * to match any character except / + escaped = string.gsub(escaped, "%*", "[^/]*") + + -- Replace placeholder with pattern that matches any path including / + escaped = string.gsub(escaped, "__DOUBLESTAR__", ".*") + + -- Special handling for **/name patterns - they should match directories + if string.match(rawPattern, "%*%*/[^/]+$") then + -- **/logs should match files within logs directories + escaped = escaped .. "/" + end + + -- Handle trailing slash - directory patterns should match everything within + if string.match(escaped, "/$") then + -- Remove trailing slash and match anything that starts with this path + escaped = string.gsub(escaped, "/$", "/") + -- Don't anchor with $ - allow matching subdirectories + elseif not string.match(escaped, "^/") then + -- Anchor non-directory patterns to match exactly + escaped = escaped .. "$" + end + + return escaped end -- matches file path substrings @@ -15,15 +43,16 @@ local function isMatch(filePath, pathPattern) if pathPattern == nil or pathPattern == "" then return false end if isComment(pathPattern) then return false end - return string.match(filePath, buildEscapedPattern(pathPattern)) ~= nil + local pattern = buildEscapedPattern(pathPattern) + return string.match(filePath, pattern) ~= nil end --- Detects `*` pattern +-- Detects `*` pattern (global match - only exact "*") local function isGlobalMatch(pathPattern) if pathPattern == nil or pathPattern == "" then return false end if isComment(pathPattern) then return false end - return string.match(pathPattern, "*") ~= nil + return pathPattern == "*" end local function collectCodeowners(group) @@ -74,10 +103,10 @@ CO.matchFilesToCodeowner = function(filePaths) local pathPattern = split[1] for _, filePath in ipairs(filePaths) do - if isMatch(filePath, pathPattern) then - table.insert(matches, { pathPattern = pathPattern, codeowners = collectCodeowners(split) }) - elseif isGlobalMatch(pathPattern) then + if isGlobalMatch(pathPattern) then globalCodeowners = collectCodeowners(split) + elseif isMatch(filePath, pathPattern) then + table.insert(matches, { pathPattern = pathPattern, codeowners = collectCodeowners(split) }) end end end @@ -87,8 +116,18 @@ CO.matchFilesToCodeowner = function(filePaths) sortMatches(matches) - local codeownersList = mapCodeowners(matches) + -- Only use the most specific pattern(s) - those with the longest pathPattern + local maxLength = #matches[1].pathPattern + local mostSpecificMatches = {} + for _, match in ipairs(matches) do + if #match.pathPattern == maxLength then + table.insert(mostSpecificMatches, match) + else + break -- Since sorted by length, we can break early + end + end + local codeownersList = mapCodeowners(mostSpecificMatches) return codeownersList end diff --git a/lua/gh-co/co.test.lua b/lua/gh-co/co.test.lua new file mode 100644 index 0000000..a69a8a3 --- /dev/null +++ b/lua/gh-co/co.test.lua @@ -0,0 +1,312 @@ +-- Add local lua path for testing +package.path = "./lua/?.lua;./lua/?/init.lua;" .. package.path + +local lu = require("luaunit") + +-- Create minimal vim global for testing +vim = { + split = function(s, sep) + local result = {} + local pattern = "([^" .. sep .. "]*)" + for match in string.gmatch(s .. sep, pattern .. sep) do + table.insert(result, match) + end + return result + end, + fs = { + dirname = function() return "/test/.github" end, + find = function() return {"/test/.github"} end, + dir = function(path) + -- Mock directory iterator - return .github directory for root, CODEOWNERS for .github + if path:match("%.github$") then + local called = false + return function() + if not called then + called = true + return "CODEOWNERS", "file" + end + return nil + end + else + -- Root directory - return .github directory + local called = false + return function() + if not called then + called = true + return ".github", "directory" + end + return nil + end + end + end + }, + loop = { + cwd = function() return "/test" end + }, + api = { + nvim_buf_get_name = function() return "" end + }, + fn = { + bufnr = function() return 0 end, + buflisted = function() return 0 end + } +} + +local CO = require("gh-co.co") + +TestCO = {} + +function TestCO:setUp() + -- Mock the FS.openCodeownersFileAsLines to return empty iterator for each test + self.FS = require("gh-co.fs") + self.originalOpenCodeownersFileAsLines = self.FS.openCodeownersFileAsLines + self.FS.openCodeownersFileAsLines = function() + return function() return nil end + end +end + +function TestCO:tearDown() + -- Restore original function + self.FS.openCodeownersFileAsLines = self.originalOpenCodeownersFileAsLines +end + +function TestCO:testMatchFilesToCodeownerEmpty() -- luacheck: ignore 212 + -- Test empty file paths returns empty list + local result = CO.matchFilesToCodeowner({}) + lu.assertEquals(result, {}) +end + +function TestCO:testGlobalPattern() -- luacheck: ignore 212 + -- Test * pattern matches all files and assigns global owners + self.FS.openCodeownersFileAsLines = function() + local lines = {"* @global-owner1 @global-owner2"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"README.md", "src/main.js"}) + lu.assertEquals(result, {"@global-owner1", "@global-owner2"}) +end + +function TestCO:testJavaScriptPattern() -- luacheck: ignore 212 + -- Test *.js pattern matches JavaScript files + self.FS.openCodeownersFileAsLines = function() + local lines = {"*.js @js-owner"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"app.js", "utils.js"}) + lu.assertEquals(result, {"@js-owner"}) +end + +function TestCO:testGoPattern() -- luacheck: ignore 212 + -- Test *.go pattern matches Go files + self.FS.openCodeownersFileAsLines = function() + local lines = {"*.go docs@example.com"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"main.go", "server.go"}) + lu.assertEquals(result, {"docs@example.com"}) +end + +function TestCO:testTxtPattern() -- luacheck: ignore 212 + -- Test *.txt pattern matches text files with team owner + self.FS.openCodeownersFileAsLines = function() + local lines = {"*.txt @octo-org/octocats"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"README.txt", "notes.txt"}) + lu.assertEquals(result, {"@octo-org/octocats"}) +end + +function TestCO:testBuildLogsDirectoryPattern() -- luacheck: ignore 212 + -- Test /build/logs/ pattern matches files in specific directory + self.FS.openCodeownersFileAsLines = function() + local lines = {"/build/logs/ @doctocat"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"/build/logs/app.log", "/build/logs/error.log"}) + lu.assertEquals(result, {"@doctocat"}) +end + +function TestCO:testDocsWildcardPattern() -- luacheck: ignore 212 + -- Test docs/* pattern matches files directly in docs directory (not subdirectories) + self.FS.openCodeownersFileAsLines = function() + local lines = {"docs/* docs@example.com"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"docs/README.md", "docs/guide.txt"}) + lu.assertEquals(result, {"docs@example.com"}) +end + +function TestCO:testDocsWildcardDoesNotMatchSubdirectories() -- luacheck: ignore 212 + -- Test docs/* pattern does NOT match files in subdirectories + self.FS.openCodeownersFileAsLines = function() + local lines = {"docs/* docs@example.com"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + -- Debug: Test what the pattern should do + -- docs/* should match "docs/readme.md" but NOT "docs/sub/readme.md" + local result = CO.matchFilesToCodeowner({"docs/sub/readme.md"}) + lu.assertEquals(result, {}) +end + +function TestCO:testCombinedWithGlobalPattern() -- luacheck: ignore 212 + -- Test that when both specific and global patterns exist, specific patterns take precedence + self.FS.openCodeownersFileAsLines = function() + local lines = { + "* @global-owner", + "*.js @js-owner", + "docs/* docs@example.com" + } + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + -- JS files should match specific owner (*.js overrides *) + local result1 = CO.matchFilesToCodeowner({"app.js"}) + lu.assertEquals(result1, {"@js-owner"}) + + -- Docs files should match docs owner (docs/* overrides *) + local result2 = CO.matchFilesToCodeowner({"docs/README.md"}) + lu.assertEquals(result2, {"docs@example.com"}) + + -- Files with no specific pattern should fall back to global owner + local result3 = CO.matchFilesToCodeowner({"README.py"}) + lu.assertEquals(result3, {"@global-owner"}) +end + +function TestCO:testAppsDirectoryPattern() -- luacheck: ignore 212 + -- Test apps/ pattern matches files in apps directory + self.FS.openCodeownersFileAsLines = function() + local lines = {"apps/ @octocat"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"apps/web/index.js", "apps/mobile/main.kt"}) + lu.assertEquals(result, {"@octocat"}) +end + +function TestCO:testRootDocsDirectoryPattern() -- luacheck: ignore 212 + -- Test /docs/ pattern matches files in root docs directory + self.FS.openCodeownersFileAsLines = function() + local lines = {"/docs/ @doctocat"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"/docs/api.md", "/docs/guides/setup.md"}) + lu.assertEquals(result, {"@doctocat"}) +end + +function TestCO:testRootScriptsDirectoryPattern() -- luacheck: ignore 212 + -- Test /scripts/ pattern with multiple owners + self.FS.openCodeownersFileAsLines = function() + local lines = {"/scripts/ @doctocat @octocat"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"/scripts/deploy.sh", "/scripts/test.py"}) + lu.assertEquals(result, {"@doctocat", "@octocat"}) +end + +function TestCO:testDoubleStarLogsPattern() -- luacheck: ignore 212 + -- Test **/logs pattern matches any logs directory at any depth + self.FS.openCodeownersFileAsLines = function() + local lines = {"**/logs @octocat"} + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + local result = CO.matchFilesToCodeowner({"build/logs/error.log", "app/server/logs/access.log", "logs/debug.log"}) + lu.assertEquals(result, {"@octocat"}) +end + +function TestCO:testAppsWithEmptyGithubSubdirectory() -- luacheck: ignore 212 + -- Test /apps/ with empty /apps/github (no owners) + self.FS.openCodeownersFileAsLines = function() + local lines = { + "/apps/ @octocat", + "/apps/github" + } + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + -- Files in /apps/github should have no owners (empty pattern overrides /apps/) + local githubResult = CO.matchFilesToCodeowner({"/apps/github/readme.md"}) + lu.assertEquals(githubResult, {}) +end + +function TestCO:testAppsWithGithubSubdirectoryOwner() -- luacheck: ignore 212 + -- Test /apps/ with /apps/github having different owner + self.FS.openCodeownersFileAsLines = function() + local lines = { + "/apps/ @octocat", + "/apps/github @doctocat" + } + local i = 0 + return function() + i = i + 1 + return lines[i] + end + end + + -- Files in /apps/github should have @doctocat (overrides /apps/) + local githubResult = CO.matchFilesToCodeowner({"/apps/github/readme.md"}) + lu.assertEquals(githubResult, {"@doctocat"}) +end + +os.exit(lu.LuaUnit.run()) diff --git a/lua/gh-co/fs.lua b/lua/gh-co/fs.lua index 17ed8be..6676f6c 100644 --- a/lua/gh-co/fs.lua +++ b/lua/gh-co/fs.lua @@ -60,7 +60,9 @@ FS.getCodeownersFilePath = function() local rootDirContents = vim.fs.dir(rootDirName) - assert(rootDirContents, "Not able to detect project root directory. Maybe you should run nvim from the root of the project.") + assert(rootDirContents, [[ + Not able to detect project root directory. Maybe you should run nvim from the root of the project. + ]]) local githubDirName = nil local docsDirName = nil diff --git a/lua/gh-co/syntax.lua b/lua/gh-co/syntax.lua index c48937c..8fd0296 100644 --- a/lua/gh-co/syntax.lua +++ b/lua/gh-co/syntax.lua @@ -5,8 +5,14 @@ local function setup_highlight_groups() vim.api.nvim_set_hl(0, "CodeownersComment", { link = "Comment" }) vim.api.nvim_set_hl(0, "CodeownersPath", { link = "Identifier" }) vim.api.nvim_set_hl(0, "CodeownersGlobalPath", { link = "Special" }) - vim.api.nvim_set_hl(0, "CodeownersOwner", { link = "String" }) - vim.api.nvim_set_hl(0, "CodeownersEmail", { link = "Constant" }) + + -- Try to use treesitter highlight groups for better theming + local has_treesitter = pcall(require, 'nvim-treesitter') + if has_treesitter and vim.fn.hlexists("@string.special") == 1 then + vim.api.nvim_set_hl(0, "CodeownersOwner", { link = "@string.special" }) + else + vim.api.nvim_set_hl(0, "CodeownersOwner", { link = "String" }) + end end local function highlight_line(bufnr, line_num, line_content) @@ -56,23 +62,18 @@ local function highlight_line(bufnr, line_num, line_content) for i = 2, #parts do local owner = parts[i] - local owner_start = remaining_line:find(vim.pesc(owner), 1, true) + local owner_start = remaining_line:find(owner, 1, true) if owner_start then local actual_start = offset + owner_start - 1 local actual_end = actual_start + #owner -- Determine highlight group based on owner format - if owner:match("^@[%w_%-]+/[%w_%-]+$") or owner:match("^@[%w_%-]+$") then + if owner:find("@") then vim.api.nvim_buf_set_extmark(bufnr, ns_id, line_num, actual_start, { end_col = actual_end, hl_group = "CodeownersOwner" }) - elseif owner:match("^[%w%.%%_%+%-]+@[%w%.%-]+%.[%w]+$") then - vim.api.nvim_buf_set_extmark(bufnr, ns_id, line_num, actual_start, { - end_col = actual_end, - hl_group = "CodeownersEmail" - }) end -- Update offset for next owner diff --git a/lua/setup.lua b/lua/setup.lua new file mode 100644 index 0000000..0dbfcdb --- /dev/null +++ b/lua/setup.lua @@ -0,0 +1,7 @@ +local version = _VERSION:match("%d+%.%d+") + +package.path = 'lua_modules/share/lua/' .. version .. + '/?.lua;lua_modules/share/lua/' .. version .. + '/?/init.lua;' .. package.path +package.cpath = 'lua_modules/lib/lua/' .. version .. + '/?.so;' .. package.cpath diff --git a/readme.MD b/readme.MD index f6feb07..87ad948 100644 --- a/readme.MD +++ b/readme.MD @@ -57,3 +57,19 @@ Show codeowners for files in all buffers. ### `:GhCoGitWho ` Show codeowners for files affected by commit SHA. + +## Development + +Project is using luarocks to manage dependencies. After cloning the repo run: + +```bash +luarocks install --deps-only gh-co.nvim-0.0.5-1.rockspec +``` + +### Lint + +Run `PATH="./lua_modules/bin:PATH" luacheck lua` to lint the codebase + +### Test + +Run `luarocks test`