diff options
| author | Stephanie Gredell <s.raide@gmail.com> | 2026-02-08 13:11:22 -0800 |
|---|---|---|
| committer | Stephanie Gredell <s.raide@gmail.com> | 2026-02-08 17:22:07 -0800 |
| commit | 17be2bff90a22d8bafc102e3ca1730bb05026841 (patch) | |
| tree | 2ece5692956be9b03bc4453371fd8f105d8a10b4 /lua/99/test | |
| parent | 489e132d4aec29970e0981ee12252d3925ad49c3 (diff) | |
| download | a4-17be2bff90a22d8bafc102e3ca1730bb05026841.tar.xz a4-17be2bff90a22d8bafc102e3ca1730bb05026841.zip | |
Add ability to reference files in prompt buffer
- type @ in the prompt to fuzzy-search and reference project files, content gets resolved and injected into LLMcontext
- moved completion provider logic out of cmp.lua into agents and files domain modules
- added completions registry to support multiple trigger characters (# rules, @ files)
- added tests for file discovery, fuzzy matching, and the completions registry
Diffstat (limited to 'lua/99/test')
| -rw-r--r-- | lua/99/test/agents_spec.lua | 70 | ||||
| -rw-r--r-- | lua/99/test/completions_spec.lua | 162 | ||||
| -rw-r--r-- | lua/99/test/files_spec.lua | 186 |
3 files changed, 411 insertions, 7 deletions
diff --git a/lua/99/test/agents_spec.lua b/lua/99/test/agents_spec.lua index 7616734..fa0a60e 100644 --- a/lua/99/test/agents_spec.lua +++ b/lua/99/test/agents_spec.lua @@ -7,15 +7,40 @@ local function a(p) end local custom_mds = { - { name = "back-end", path = a("scratch/custom_rules/back-end/SKILL.md") }, - { name = "foo", path = a("scratch/custom_rules/foo/SKILL.md") }, - { name = "front-end", path = a("scratch/custom_rules/front-end/SKILL.md") }, - { name = "vim.lsp", path = a("scratch/custom_rules/vim.lsp/SKILL.md") }, - { name = "vim", path = a("scratch/custom_rules/vim/SKILL.md") }, - { name = "vim", path = a("scratch/custom_rules_2/vim/SKILL.md") }, + { + name = "back-end", + path = "scratch/custom_rules/back-end/SKILL.md", + absolute_path = a("scratch/custom_rules/back-end/SKILL.md"), + }, + { + name = "foo", + path = "scratch/custom_rules/foo/SKILL.md", + absolute_path = a("scratch/custom_rules/foo/SKILL.md"), + }, + { + name = "front-end", + path = "scratch/custom_rules/front-end/SKILL.md", + absolute_path = a("scratch/custom_rules/front-end/SKILL.md"), + }, + { + name = "vim.lsp", + path = "scratch/custom_rules/vim.lsp/SKILL.md", + absolute_path = a("scratch/custom_rules/vim.lsp/SKILL.md"), + }, + { + name = "vim", + path = "scratch/custom_rules/vim/SKILL.md", + absolute_path = a("scratch/custom_rules/vim/SKILL.md"), + }, + { + name = "vim", + path = "scratch/custom_rules_2/vim/SKILL.md", + absolute_path = a("scratch/custom_rules_2/vim/SKILL.md"), + }, { name = "vim.treesitter", - path = a("scratch/custom_rules/vim.treesitter/SKILL.md"), + path = "scratch/custom_rules/vim.treesitter/SKILL.md", + absolute_path = a("scratch/custom_rules/vim.treesitter/SKILL.md"), }, } @@ -72,6 +97,7 @@ describe("rules: <name>/SKILL.md", function() end end end) + it("find rules", function() local _99 = r({ "scratch/custom_rules/", @@ -84,4 +110,34 @@ describe("rules: <name>/SKILL.md", function() eq({ "front-end" }, found.names) eq(rules.by_name["front-end"], found.rules) end) + + it("should validate that tokens exist by path and name", function() + local _99 = r({ + "scratch/custom_rules/", + "scratch/custom_rules_2/", + }) + local rules = Agents.rules(_99) + + -- Test by path + eq(true, Agents.is_rule(rules, "scratch/custom_rules/back-end/SKILL.md")) + eq(true, Agents.is_rule(rules, "scratch/custom_rules/foo/SKILL.md")) + eq(true, Agents.is_rule(rules, "scratch/custom_rules/front-end/SKILL.md")) + eq(true, Agents.is_rule(rules, "scratch/custom_rules/vim.lsp/SKILL.md")) + eq(true, Agents.is_rule(rules, "scratch/custom_rules/vim/SKILL.md")) + eq( + true, + Agents.is_rule(rules, "scratch/custom_rules/vim.treesitter/SKILL.md") + ) + + -- Test by name + eq(true, Agents.is_rule(rules, "back-end")) + eq(true, Agents.is_rule(rules, "foo")) + eq(true, Agents.is_rule(rules, "front-end")) + eq(true, Agents.is_rule(rules, "vim")) + + -- Test invalid + eq(false, Agents.is_rule(rules, "nonexistent")) + eq(false, Agents.is_rule(rules, "invalid-token")) + eq(false, Agents.is_rule(rules, "")) + end) end) diff --git a/lua/99/test/completions_spec.lua b/lua/99/test/completions_spec.lua new file mode 100644 index 0000000..3385713 --- /dev/null +++ b/lua/99/test/completions_spec.lua @@ -0,0 +1,162 @@ +-- luacheck: globals describe it assert before_each +---@diagnostic disable: undefined-field, missing-fields +local Completions = require("99.extensions.completions") +local eq = assert.are.same + +local function mock_provider(trigger, name, valid_tokens) + return { + trigger = trigger, + name = name, + get_items = function() + local items = {} + for token, _ in pairs(valid_tokens) do + table.insert(items, { + label = token, + insertText = trigger .. token, + filterText = trigger .. token, + kind = 1, + }) + end + return items + end, + is_valid = function(token) + return valid_tokens[token] ~= nil + end, + resolve = function(token) + return valid_tokens[token] + end, + } +end + +describe("completions", function() + before_each(function() + Completions._reset() + end) + + it("register and get_trigger_characters", function() + Completions.register(mock_provider("#", "rules", {})) + Completions.register(mock_provider("@", "files", {})) + eq({ "#", "@" }, Completions.get_trigger_characters()) + end) + + it("register replaces provider with same trigger", function() + Completions.register( + mock_provider("#", "rules-v1", { old = "old-content" }) + ) + Completions.register( + mock_provider("#", "rules-v2", { new = "new-content" }) + ) + + local triggers = Completions.get_trigger_characters() + eq({ "#" }, triggers) + + local refs = Completions.parse("use #new in prompt") + eq(1, #refs) + eq("new-content", refs[1].content) + + local old_refs = Completions.parse("use #old in prompt") + eq(0, #old_refs) + end) + + it("get_keyword_pattern builds pattern from triggers", function() + Completions.register(mock_provider("#", "rules", {})) + Completions.register(mock_provider("@", "files", {})) + eq("[#@]\\k*", Completions.get_keyword_pattern()) + end) + + it("get_completions returns items for known trigger", function() + Completions.register(mock_provider("#", "rules", { debug = "content" })) + local items = Completions.get_completions("#") + eq(1, #items) + eq("debug", items[1].label) + eq("#debug", items[1].insertText) + end) + + it("get_completions returns empty for unknown trigger", function() + Completions.register(mock_provider("#", "rules", {})) + eq({}, Completions.get_completions("@")) + end) + + it("parse extracts valid tokens and resolves content", function() + Completions.register(mock_provider("#", "rules", { + ["debug.md"] = "<debug>content</debug>", + })) + Completions.register(mock_provider("@", "files", { + ["utils.lua"] = "```lua\ncode\n```", + })) + + local refs = Completions.parse("add logging #debug.md and read @utils.lua") + eq(2, #refs) + eq("<debug>content</debug>", refs[1].content) + eq("```lua\ncode\n```", refs[2].content) + end) + + it("parse skips invalid tokens", function() + Completions.register(mock_provider("#", "rules", { + ["valid.md"] = "resolved", + })) + + local refs = Completions.parse("#valid.md #nonexistent") + eq(1, #refs) + eq("resolved", refs[1].content) + end) + + it("parse returns empty for no tokens", function() + Completions.register(mock_provider("#", "rules", { a = "b" })) + eq({}, Completions.parse("just a plain prompt")) + end) + + it("real providers register and resolve through the registry", function() + local Agents = require("99.extensions.agents") + local Files = require("99.extensions.files") + + -- Set up files module + local default_exclude = { + ".env", + ".env.*", + "node_modules", + ".git", + "dist", + "build", + "*.log", + ".DS_Store", + "tmp", + ".cursor", + } + Files.setup({ enabled = true, exclude = default_exclude }, {}) + Files.set_project_root(vim.uv.cwd() or "") + Files.discover_files() + + -- Build a minimal state + local state = { + rules = Agents.rules({ + completion = { + cursor_rules = "scratch/cursor/rules/", + custom_rules = {}, + }, + }), + } + + -- Register real providers through the registry + Completions.register(Agents.completion_provider(state)) + Completions.register(Files.completion_provider()) + + -- Verify triggers registered + local triggers = Completions.get_trigger_characters() + eq(2, #triggers) + + -- Parse a prompt with a real @file reference + local refs = Completions.parse("check @scratch/refresh.lua") + assert.is_true(#refs > 0, "expected at least one resolved reference") + + -- Verify resolved content is a real code fence with non-empty body + assert.is_true( + refs[1].content:sub(1, 6) == "```lua", + "expected code fence from real file provider" + ) + assert.is_true(#refs[1].content > 20, "expected non-trivial file content") + + -- Clean up + Files.set_project_root("") + end) +end) diff --git a/lua/99/test/files_spec.lua b/lua/99/test/files_spec.lua new file mode 100644 index 0000000..217a199 --- /dev/null +++ b/lua/99/test/files_spec.lua @@ -0,0 +1,186 @@ +-- luacheck: globals describe it assert before_each after_each +---@diagnostic disable: undefined-field, need-check-nil +local Files = require("99.extensions.files") +local eq = assert.are.same + +describe("files", function() + local default_exclude = { + ".env", + ".env.*", + "node_modules", + ".git", + "dist", + "build", + "*.log", + ".DS_Store", + "tmp", + ".cursor", + } + + before_each(function() + Files.setup({ enabled = true, exclude = default_exclude }, {}) + Files.set_project_root(vim.uv.cwd()) + end) + + after_each(function() + Files.set_project_root("") + end) + + it("discover_files finds known files and excludes .git", function() + local files = Files.discover_files() + local paths = {} + for _, f in ipairs(files) do + paths[f.path] = f + end + + -- known fixture files must be present + assert.is_not_nil(paths["scratch/refresh.lua"]) + assert.is_not_nil(paths["scratch/test.ts"]) + eq("refresh.lua", paths["scratch/refresh.lua"].name) + eq("test.ts", paths["scratch/test.ts"].name) + + -- .git must be excluded + for path, _ in pairs(paths) do + assert.is_nil( + path:match("^%.git/"), + "expected .git to be excluded but found: " .. path + ) + end + end) + + it("discover_files returns sorted paths", function() + local files = Files.discover_files() + for i = 2, #files do + assert.is_true( + files[i - 1].path < files[i].path, + "expected sorted order but " + .. files[i - 1].path + .. " >= " + .. files[i].path + ) + end + end) + + it("is_project_file by path and name, rejects invalid", function() + Files.discover_files() + eq(true, Files.is_project_file("scratch/refresh.lua")) + eq(true, Files.is_project_file("refresh.lua")) + eq(false, Files.is_project_file("nonexistent/file.lua")) + eq(false, Files.is_project_file("")) + end) + + it("find_matches fuzzy matches non-contiguous characters", function() + Files.discover_files() + + -- "rfrsh" should fuzzy match "refresh.lua" (r-f-r-s-h appear in order) + local matches = Files.find_matches("rfrsh") + local found = false + for _, f in ipairs(matches) do + if f.name == "refresh.lua" then + found = true + end + end + assert.is_true(found, "expected 'rfrsh' to fuzzy match refresh.lua") + + -- "zzzzz" should match nothing + local no_matches = Files.find_matches("zzzzz") + eq(0, #no_matches) + end) + + it("read_file returns actual file content", function() + local content = Files.read_file("scratch/refresh.lua") + assert.is_not_nil(content) + assert.is_true(#content > 0, "expected non-empty file content") + end) + + it("read_file returns nil for missing file", function() + eq(nil, Files.read_file("nonexistent/file.lua")) + end) + + it("setup excludes configured patterns and keeps others", function() + Files.setup( + { enabled = true, exclude = { "scratch", ".git", "node_modules" } }, + {} + ) + Files.set_project_root(vim.uv.cwd()) + local files = Files.discover_files() + + local has_non_scratch = false + for _, f in ipairs(files) do + assert.is_nil( + f.path:match("^scratch"), + "expected scratch excluded but found: " .. f.path + ) + if not f.path:match("^scratch") then + has_non_scratch = true + end + end + assert.is_true( + has_non_scratch, + "expected non-scratch files to still be present" + ) + end) + + it( + "completion_provider get_items returns items with correct shape and values", + function() + Files.discover_files() + local provider = Files.completion_provider() + + eq("@", provider.trigger) + eq("files", provider.name) + + local items = provider.get_items() + assert.is_true(#items > 0) + + -- find the refresh.lua item specifically and check every field + local refresh_item = nil + for _, item in ipairs(items) do + if item.label == "refresh.lua" then + refresh_item = item + end + end + assert.is_not_nil( + refresh_item, + "expected to find refresh.lua in completion items" + ) + eq("@scratch/refresh.lua", refresh_item.insertText) + assert.is_true( + refresh_item.filterText:match("refresh%.lua") ~= nil, + "expected filterText to contain filename" + ) + eq(17, refresh_item.kind) -- LSP CompletionItemKind.Reference + eq("scratch/refresh.lua", refresh_item.detail) + eq("markdown", refresh_item.documentation.kind) + end + ) + + it( + "completion_provider resolve wraps content in code fence with extension", + function() + local provider = Files.completion_provider() + local content = provider.resolve("scratch/refresh.lua") + assert.is_not_nil(content) + + assert.is_true( + content:sub(1, 6) == "```lua", + "expected code fence to start with ```lua" + ) + assert.is_true( + content:sub(-4) == "\n```", + "expected code fence to end with ```" + ) + assert.is_true( + content:match("-- scratch/refresh%.lua") ~= nil, + "expected path comment in fence" + ) + local inner = content:match("```lua\n.-\n(.+)\n```$") + assert.is_not_nil(inner, "expected non-empty content inside code fence") + end + ) + + it("completion_provider resolve returns nil for missing file", function() + local provider = Files.completion_provider() + eq(nil, provider.resolve("does/not/exist.lua")) + end) +end) |
