summaryrefslogtreecommitdiff
path: root/lua/99/test
diff options
context:
space:
mode:
authorStephanie Gredell <s.raide@gmail.com>2026-02-08 13:11:22 -0800
committerStephanie Gredell <s.raide@gmail.com>2026-02-08 17:22:07 -0800
commit17be2bff90a22d8bafc102e3ca1730bb05026841 (patch)
tree2ece5692956be9b03bc4453371fd8f105d8a10b4 /lua/99/test
parent489e132d4aec29970e0981ee12252d3925ad49c3 (diff)
downloada4-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.lua70
-rw-r--r--lua/99/test/completions_spec.lua162
-rw-r--r--lua/99/test/files_spec.lua186
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)