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 | |
| 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
| -rw-r--r-- | README.md | 40 | ||||
| -rw-r--r-- | lua/99/extensions/agents/helpers.lua | 8 | ||||
| -rw-r--r-- | lua/99/extensions/agents/init.lua | 63 | ||||
| -rw-r--r-- | lua/99/extensions/cmp.lua | 123 | ||||
| -rw-r--r-- | lua/99/extensions/completions.lua | 83 | ||||
| -rw-r--r-- | lua/99/extensions/files/init.lua | 282 | ||||
| -rw-r--r-- | lua/99/extensions/init.lua | 8 | ||||
| -rw-r--r-- | lua/99/init.lua | 8 | ||||
| -rw-r--r-- | lua/99/ops/over-range.lua | 6 | ||||
| -rw-r--r-- | lua/99/request-context.lua | 11 | ||||
| -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 | ||||
| -rw-r--r-- | scripts/ci/install_treesitter_parsers.lua | 107 | ||||
| -rw-r--r-- | syntax/99prompt.vim | 14 |
15 files changed, 1027 insertions, 144 deletions
@@ -21,7 +21,7 @@ is for people who dont have "skill issues." This is meant to streamline the req 3. Still very alpha, could have severe problems ## How to use -**you must have opencode installed and setup** +**you must have a supported AI CLI installed (opencode, claude, or cursor-agent — see [Providers](#providers) below)** Add the following configuration to your neovim config @@ -38,15 +38,15 @@ I make the assumption you are using Lazy local cwd = vim.uv.cwd() local basename = vim.fs.basename(cwd) _99.setup({ + -- provider = _99.ClaudeCodeProvider, -- default: OpenCodeProvider logger = { level = _99.DEBUG, path = "/tmp/" .. basename .. ".99.debug", print_on_error = true, }, - --- A new feature that is centered around tags + --- Completions: #rules and @files in the prompt buffer completion = { - --- Defaults to .cursor/rules -- I am going to disable these until i understand the -- problem better. Inside of cursor rules there is also -- application rules, which means i need to apply these @@ -69,6 +69,14 @@ I make the assumption you are using Lazy "scratch/custom_rules/", }, + --- Configure @file completion (all fields optional, sensible defaults) + files = { + -- enabled = true, + -- max_file_size = 102400, -- bytes, skip files larger than this + -- max_files = 5000, -- cap on total discovered files + -- exclude = { ".env", ".env.*", "node_modules", ".git", ... }, + }, + --- What autocomplete do you use. We currently only --- support cmp right now source = "cmp", @@ -107,10 +115,30 @@ I make the assumption you are using Lazy }, ``` -## Completion -When prompting, if you have cmp installed as your autocomplete you can use an autocomplete for rule inclusion in your prompt. +## Completions +When prompting, you can reference rules and files to add context to your request. + +- `#` references rules — type `#` in the prompt to autocomplete rule files from your configured rule directories +- `@` references files — type `@` to fuzzy-search project files + +Referenced content is automatically resolved and injected into the AI context. Requires cmp (`source = "cmp"` in your completion config). -How skill completion and inclusion works is that you start by typing `@`. +## Providers +99 supports multiple AI CLI backends. Set `provider` in your setup to switch. If you don't set `model`, the provider's default is used. + +| Provider | CLI tool | Default model | +|---|---|---| +| `OpenCodeProvider` (default) | `opencode` | `opencode/claude-sonnet-4-5` | +| `ClaudeCodeProvider` | `claude` | `claude-sonnet-4-5` | +| `CursorAgentProvider` | `cursor-agent` | `sonnet-4.5` | + +```lua +_99.setup({ + provider = _99.ClaudeCodeProvider, + -- model is optional, overrides the provider's default + model = "claude-sonnet-4-5", +}) +``` ## API You can see the full api at [99 API](./lua/99/init.lua) diff --git a/lua/99/extensions/agents/helpers.lua b/lua/99/extensions/agents/helpers.lua index 94df33b..8ac6f73 100644 --- a/lua/99/extensions/agents/helpers.lua +++ b/lua/99/extensions/agents/helpers.lua @@ -28,11 +28,17 @@ function M.ls(dir) end local rules = {} + local cwd = vim.uv.cwd() for _, file in ipairs(files) do local filename = vim.fn.fnamemodify(file, ":h:t") + local relative_path = file + if cwd and file:sub(1, #cwd) == cwd then + relative_path = file:sub(#cwd + 2) -- +2 to skip the trailing slash + end table.insert(rules, { name = filename, - path = file, + path = relative_path, + absolute_path = file, }) end diff --git a/lua/99/extensions/agents/init.lua b/lua/99/extensions/agents/init.lua index eb24e76..c53429e 100644 --- a/lua/99/extensions/agents/init.lua +++ b/lua/99/extensions/agents/init.lua @@ -5,6 +5,7 @@ local M = {} --- @class _99.Agents.Rule --- @field name string --- @field path string +--- @field absolute_path string? --- @class _99.Agents.Rules --- @field custom _99.Agents.Rule[] @@ -24,8 +25,8 @@ local function add_rule_by_name(map, rules) end end ----@param _99 _99.State ----@return _99.Agents.Rules +--- @param _99 _99.State +--- @return _99.Agents.Rules function M.rules(_99) local custom = {} for _, path in ipairs(_99.completion.custom_rules or {}) do @@ -54,8 +55,8 @@ function M.rules_to_items(rules) end --- @param rules _99.Agents.Rules ----@param path string ----@return _99.Agents.Rule | nil +--- @param path string +--- @return _99.Agents.Rule | nil function M.get_rule_by_path(rules, path) for _, rule in ipairs(rules.custom or {}) do if rule.path == path then @@ -66,11 +67,11 @@ function M.get_rule_by_path(rules, path) end --- @param rules _99.Agents.Rules ----@param token string ----@return boolean +--- @param token string +--- @return boolean function M.is_rule(rules, token) for _, rule in ipairs(rules.custom or {}) do - if rule.path == token then + if rule.path == token or rule.name == token then return true end end @@ -95,9 +96,9 @@ function M.find_rules(rules, haystack) return out end ----@param rules _99.Agents.Rules ----@param prompt string ----@return {names: string[], rules: _99.Agents.Rules[]} +--- @param rules _99.Agents.Rules +--- @param prompt string +--- @return {names: string[], rules: _99.Agents.Rules[]} function M.by_name(rules, prompt) --- @type table<string, boolean> local found = {} @@ -127,4 +128,46 @@ function M.by_name(rules, prompt) } end +--- @param _99 _99.State +--- @return _99.CompletionProvider +function M.completion_provider(_99) + return { + trigger = "#", + name = "rules", + get_items = function() + local agent_rules = M.rules_to_items(_99.rules) + local items = {} + for _, rule in ipairs(agent_rules) do + local docs = helpers.head(rule.absolute_path or rule.path) + table.insert(items, { + label = rule.name, + insertText = "#" .. rule.path, + filterText = "#" .. rule.name, + kind = 12, -- LSP CompletionItemKind.Value + documentation = { kind = "markdown", value = docs }, + detail = "Rule: " .. rule.path, + }) + end + return items + end, + is_valid = function(token) + return M.is_rule(_99.rules, token) + end, + resolve = function(token) + local rule = M.get_rule_by_path(_99.rules, token) + if not rule then + return nil + end + local file_path = rule.absolute_path or rule.path + local ok, file = pcall(io.open, file_path, "r") + if not ok or not file then + return nil + end + local content = file:read("*a") + file:close() + return string.format("<%s>\n%s\n</%s>", rule.name, content, rule.name) + end, + } +end + return M diff --git a/lua/99/extensions/cmp.lua b/lua/99/extensions/cmp.lua index 3fd34f5..4279634 100644 --- a/lua/99/extensions/cmp.lua +++ b/lua/99/extensions/cmp.lua @@ -1,28 +1,10 @@ local Agents = require("99.extensions.agents") -local Helpers = require("99.extensions.agents.helpers") +local Files = require("99.extensions.files") +local Completions = require("99.extensions.completions") local SOURCE = "99" ---- @class _99.Extensions.CmpItem ---- @field rule _99.Agents.Rule ---- @field docs string - ---- @param _99 _99.State ---- @return _99.Extensions.CmpItem[] -local function rules(_99) - local agent_rules = Agents.rules_to_items(_99.rules) - local out = {} - for _, rule in ipairs(agent_rules) do - table.insert(out, { - rule = rule, - docs = Helpers.head(rule.path), - }) - end - return out -end - --- @class CmpSource --- @field _99 _99.State ---- @field items _99.Extensions.CmpItem[] local CmpSource = {} CmpSource.__index = CmpSource @@ -30,7 +12,6 @@ CmpSource.__index = CmpSource function CmpSource.new(_99) return setmetatable({ _99 = _99, - items = rules(_99), }, CmpSource) end @@ -43,44 +24,41 @@ function CmpSource.get_debug_name() end function CmpSource.get_keyword_pattern() - return [[@\k\+]] + return Completions.get_keyword_pattern() end function CmpSource.get_trigger_characters() - return { "@" } + return Completions.get_trigger_characters() end ---- @class CompletionItem ---- @field label string ---- @field kind number kind is optional but gives icons / categories ---- @field documentation string can be a string or markdown table ---- @field detail string detail shows a right-side hint +function CmpSource.complete(_, params, callback) + local before = params.context.cursor_before_line or "" ---- @class Completion ---- @field items CompletionItem[] ---- @field isIncomplete boolean - --- true: I might return more if user types more --- false: this result set is complete -function CmpSource:complete(_, callback) - local items = {} --[[ @as CompletionItem[] ]] - for _, item in ipairs(self.items) do - table.insert(items, { - label = item.rule.name, - insertText = "@" .. item.rule.name, - filterText = "@" .. item.rule.name, - kind = 17, -- file - documentation = { - kind = "markdown", - value = item.docs, - }, - detail = item.rule.path, - }) + -- Find which trigger is active + local trigger = nil + for _, char in ipairs(Completions.get_trigger_characters()) do + local pattern = char:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1") .. "%S*$" + if before:match(pattern) then + trigger = char + break + end end - callback({ - items = items, - isIncomplete = false, - }) + if not trigger then + callback({ items = {}, isIncomplete = false }) + return + end + + local items = Completions.get_completions(trigger) + callback({ items = items, isIncomplete = false }) +end + +function CmpSource.resolve(_, completion_item, callback) + callback(completion_item) +end + +function CmpSource.execute(_, completion_item, callback) + callback(completion_item) end --- @type CmpSource | nil @@ -88,29 +66,52 @@ local source = nil --- @param _ _99.State local function init_for_buffer(_) + local buf = vim.api.nvim_get_current_buf() + + -- Set filetype for syntax highlighting + vim.bo[buf].filetype = "99prompt" + local cmp = require("cmp") cmp.setup.buffer({ - sources = { - { name = SOURCE }, - }, + sources = { { name = SOURCE } }, window = { - completion = { - zindex = 1001, - }, - documentation = { - zindex = 1001, - }, + completion = { zindex = 1001 }, + documentation = { zindex = 1001 }, }, }) end --- @param _99 _99.State +local function register_providers(_99) + Completions.register(Agents.completion_provider(_99)) + Completions.register(Files.completion_provider()) +end + +--- @param _99 _99.State local function init(_99) assert( source == nil, "the source must be nil when calling init on an completer" ) + -- Collect rule directories to exclude from file search + local rule_dirs = {} + if _99.completion then + if _99.completion.custom_rules then + for _, dir in ipairs(_99.completion.custom_rules) do + table.insert(rule_dirs, dir) + end + end + end + + if _99.completion and _99.completion.files then + Files.setup(_99.completion.files, rule_dirs) + else + Files.setup({ enabled = true }, rule_dirs) + end + + register_providers(_99) + local cmp = require("cmp") source = CmpSource.new(_99) cmp.register_source(SOURCE, source) @@ -121,7 +122,7 @@ local function refresh_state(_99) if not source then return end - source.items = rules(_99) + register_providers(_99) end --- @type _99.Extensions.Source diff --git a/lua/99/extensions/completions.lua b/lua/99/extensions/completions.lua new file mode 100644 index 0000000..1ba36ca --- /dev/null +++ b/lua/99/extensions/completions.lua @@ -0,0 +1,83 @@ +--- A provider for completion tokens (#rules, @files) used in the prompt. +--- @class _99.CompletionProvider +--- @field trigger string +--- @field name string +--- @field get_items fun(): CompletionItem[] +--- @field is_valid fun(token: string): boolean +--- @field resolve fun(token: string): string|nil + +--- @class _99.Reference +--- @field content string + +--- @type _99.CompletionProvider[] +local providers = {} + +local M = {} + +--- @param provider _99.CompletionProvider +function M.register(provider) + for i, p in ipairs(providers) do + if p.trigger == provider.trigger then + providers[i] = provider + return + end + end + table.insert(providers, provider) +end + +--- @return string[] +function M.get_trigger_characters() + local chars = {} + for _, p in ipairs(providers) do + table.insert(chars, p.trigger) + end + return chars +end + +--- @return string +function M.get_keyword_pattern() + local triggers = {} + for _, p in ipairs(providers) do + table.insert(triggers, p.trigger) + end + return "[" .. table.concat(triggers) .. "]\\k*" +end + +--- @param prompt_text string +--- @return _99.Reference[] +function M.parse(prompt_text) + local refs = {} + for _, provider in ipairs(providers) do + local pattern = provider.trigger:gsub( + "([%%%^%$%(%)%.%[%]%*%+%-%?])", + "%%%1" + ) .. "%S+" + for word in prompt_text:gmatch(pattern) do + local token = word:sub(#provider.trigger + 1) + if provider.is_valid(token) then + local content = provider.resolve(token) + if content then + table.insert(refs, { content = content }) + end + end + end + end + return refs +end + +--- @param trigger_char string +--- @return CompletionItem[] +function M.get_completions(trigger_char) + for _, provider in ipairs(providers) do + if provider.trigger == trigger_char then + return provider.get_items() + end + end + return {} +end + +function M._reset() + providers = {} +end + +return M diff --git a/lua/99/extensions/files/init.lua b/lua/99/extensions/files/init.lua new file mode 100644 index 0000000..2b0ba57 --- /dev/null +++ b/lua/99/extensions/files/init.lua @@ -0,0 +1,282 @@ +local M = {} + +--- @class _99.Files.Config +--- @field enabled boolean? +--- @field max_file_size number? +--- @field max_files number? +--- @field exclude string[]? + +--- @class _99.Files.File +--- @field path string -- Relative path from project root +--- @field name string -- Filename +--- @field absolute_path string -- Full absolute path + +local cache = { + files = {}, + root = "", +} + +local config = { + enabled = true, + max_file_size = 100 * 1024, + max_files = 5000, + exclude = { + ".env", + ".env.*", + "node_modules", + ".git", + "dist", + "build", + "*.log", + ".DS_Store", + "tmp", + ".cursor", + }, +} + +--- @param pattern string +--- @return boolean +local function matches_exclude_pattern(pattern) + for _, exclude_pattern in ipairs(config.exclude) do + local glob_pattern = exclude_pattern:gsub("%.", "%%."):gsub("%*", ".*") + + if + pattern:match(glob_pattern .. "$") + or pattern:match("^" .. glob_pattern) + or pattern:match("/" .. glob_pattern .. "/") + then + return true + end + end + return false +end + +--- @param path string +--- @param root string +--- @return string +local function get_relative_path(path, root) + if path:sub(1, #root) == root then + local rel = path:sub(#root + 1) + if rel:sub(1, 1) == "/" then + rel = rel:sub(2) + end + return rel + end + return path +end + +--- @param root string +function M.set_project_root(root) + cache.root = root + cache.files = {} +end + +--- @return string +function M.get_project_root() + return cache.root +end + +--- @return _99.Files.File[] +function M.discover_files() + local root = cache.root + if root == "" then + return {} + end + + local files = {} + local count = 0 + + local function scan_dir(dir) + if count >= config.max_files then + return + end + + local handle = vim.uv.fs_scandir(dir) + if not handle then + return + end + + while true do + local name, type = vim.uv.fs_scandir_next(handle) + if not name then + break + end + + local full_path = dir .. "/" .. name + local rel_path = get_relative_path(full_path, root) + + if matches_exclude_pattern(name) or matches_exclude_pattern(rel_path) then + goto continue + end + + if type == "directory" then + scan_dir(full_path) + elseif type == "file" then + table.insert(files, { + path = rel_path, + name = name, + absolute_path = full_path, + }) + count = count + 1 + + if count >= config.max_files then + break + end + end + + ::continue:: + end + end + + scan_dir(root) + + table.sort(files, function(a, b) + return a.path < b.path + end) + + cache.files = files + return files +end + +--- @return _99.Files.File[] +function M.get_files() + if #cache.files == 0 and cache.root ~= "" then + return M.discover_files() + end + return cache.files +end + +--- @param query string +--- @return _99.Files.File[] +function M.find_matches(query) + local files = M.get_files() + if not query or query == "" then + return files + end + + query = query:lower() + local matches = {} + + for _, file in ipairs(files) do + local searchable = (file.name .. " " .. file.path):lower() + local match_pos = 1 + local matched = true + for i = 1, #query do + local char = query:sub(i, i) + local found = searchable:find(char, match_pos, true) + if not found then + matched = false + break + end + match_pos = found + 1 + end + + if matched then + table.insert(matches, file) + end + end + + return matches +end + +--- @param path string +--- @return string | nil +function M.read_file(path) + local full_path = cache.root .. "/" .. path + + if path:sub(1, 1) == "/" then + full_path = path + end + + local stat = vim.uv.fs_stat(full_path) + if not stat then + return nil + end + + if stat.size > config.max_file_size then + return nil + end + + local fd = vim.uv.fs_open(full_path, "r", 438) + if not fd then + return nil + end + + local data = vim.uv.fs_read(fd, stat.size, 0) + vim.uv.fs_close(fd) + + return data +end + +--- @param path string +--- @return boolean +function M.is_project_file(path) + local files = M.get_files() + for _, file in ipairs(files) do + if file.path == path or file.name == path then + return true + end + end + return false +end + +--- @param opts _99.Files.Config? +--- @param rule_dirs string[]? Directories containing rules to exclude from file search +function M.setup(opts, rule_dirs) + if opts then + config.enabled = opts.enabled ~= false + config.max_file_size = opts.max_file_size or config.max_file_size + config.max_files = opts.max_files or config.max_files + if opts.exclude then + config.exclude = opts.exclude + end + end + + -- Add rule directories to exclude list + if rule_dirs then + for _, dir in ipairs(rule_dirs) do + -- Normalize the directory path (remove trailing slash, get basename for relative paths) + local normalized = dir:gsub("/$", ""):gsub("^%./", "") + table.insert(config.exclude, normalized) + end + end +end + +--- @return _99.CompletionProvider +function M.completion_provider() + return { + trigger = "@", + name = "files", + get_items = function() + local files = M.find_matches("") + local items = {} + for _, file in ipairs(files) do + table.insert(items, { + label = file.name, + insertText = "@" .. file.path, + filterText = "@" .. file.name .. " " .. file.path, + kind = 17, -- LSP CompletionItemKind.Reference + documentation = { + kind = "markdown", + value = "File: `" .. file.path .. "`", + }, + detail = file.path, + }) + end + return items + end, + is_valid = function(token) + return M.is_project_file(token) + end, + resolve = function(token) + local content = M.read_file(token) + if not content then + return nil + end + local ext = token:match("%.([^%.]+)$") or "" + return string.format("```%s\n-- %s\n%s\n```", ext, token, content) + end, + } +end + +return M diff --git a/lua/99/extensions/init.lua b/lua/99/extensions/init.lua index debc1a8..95629a2 100644 --- a/lua/99/extensions/init.lua +++ b/lua/99/extensions/init.lua @@ -1,3 +1,5 @@ +local Files = require("99.extensions.files") + --- @class _99.Extensions.Source --- @field init_for_buffer fun(_99: _99.State): nil --- @field init fun(_99: _99.State): nil @@ -26,6 +28,12 @@ return { source.init(_99) end, + capture_project_root = function() + local cwd = vim.fn.getcwd() + local git_root = vim.fs.root(cwd, ".git") + Files.set_project_root(git_root or cwd) + end, + --- @param _99 _99.State setup_buffer = function(_99) local source = get_source(_99.completion) diff --git a/lua/99/init.lua b/lua/99/init.lua index 2340065..d48c26d 100644 --- a/lua/99/init.lua +++ b/lua/99/init.lua @@ -90,6 +90,7 @@ end --- @class _99.Completion --- @field source "cmp" | nil --- @field custom_rules string[] +--- @field files _99.Files.Config? --- @class _99.Options --- @field logger _99.Logger.Options? @@ -336,7 +337,10 @@ end --- @param opts _99.ops.Opts function _99.visual_prompt(opts) - warn("use visual, visual_prompt has been deprecated") + vim.notify( + "use visual, visual_prompt has been deprecated", + vim.log.levels.WARN + ) _99.visual(opts) end @@ -445,6 +449,7 @@ function _99.setup(opts) } _99_state.completion.custom_rules = _99_state.completion.custom_rules or {} _99_state.auto_add_skills = opts.auto_add_skills or false + _99_state.completion.files = _99_state.completion.files or {} local crules = _99_state.completion.custom_rules for i, rule in ipairs(crules) do @@ -482,6 +487,7 @@ function _99.setup(opts) _99_state:refresh_rules() Languages.initialize(_99_state) Extensions.init(_99_state) + Extensions.capture_project_root() end --- @param md string diff --git a/lua/99/ops/over-range.lua b/lua/99/ops/over-range.lua index 209be9f..efb0a93 100644 --- a/lua/99/ops/over-range.lua +++ b/lua/99/ops/over-range.lua @@ -3,7 +3,7 @@ local RequestStatus = require("99.ops.request_status") local Mark = require("99.ops.marks") local geo = require("99.geo") local make_clean_up = require("99.ops.clean-up") -local Agents = require("99.extensions.agents") +local Completions = require("99.extensions.completions") local Range = geo.Range local Point = geo.Point @@ -50,8 +50,8 @@ local function over_range(context, range, opts) full_prompt = context._99.prompts.prompts.prompt(additional_prompt, full_prompt) - local rules = Agents.find_rules(context._99.rules, additional_prompt) - context:add_agent_rules(rules) + local refs = Completions.parse(additional_prompt) + context:add_references(refs) end local additional_rules = opts.additional_rules diff --git a/lua/99/request-context.lua b/lua/99/request-context.lua index 74aaa0b..358cdd2 100644 --- a/lua/99/request-context.lua +++ b/lua/99/request-context.lua @@ -64,7 +64,8 @@ function RequestContext:add_agent_rules(rules) for _, rule in ipairs(rules) do -- Handle both string paths and rule objects self.logger:debug("adding custom rule to agent", "rule", rule) - local ok, file = pcall(io.open, rule.path, "r") + local file_path = rule.absolute_path or rule.path + local ok, file = pcall(io.open, file_path, "r") if ok and file then local content = file:read("*a") file:close() @@ -91,6 +92,14 @@ function RequestContext:add_agent_rules(rules) end end +--- @param refs _99.Reference[] +function RequestContext:add_references(refs) + for _, ref in ipairs(refs) do + self.logger:debug("adding reference to context") + table.insert(self.ai_context, ref.content) + end +end + function RequestContext:_read_md_files() local cwd = vim.uv.cwd() local dir = vim.fn.fnamemodify(self.full_path, ":h") 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) diff --git a/scripts/ci/install_treesitter_parsers.lua b/scripts/ci/install_treesitter_parsers.lua index 6393fd8..b0f59a4 100644 --- a/scripts/ci/install_treesitter_parsers.lua +++ b/scripts/ci/install_treesitter_parsers.lua @@ -1,72 +1,71 @@ local install_dir = vim.fn.stdpath("data") .. "/site" local ok_setup, setup_err = pcall(function() - require("nvim-treesitter").setup({ - install_dir = install_dir, - }) + require("nvim-treesitter").setup({ + install_dir = install_dir, + }) end) if not ok_setup then - vim.api.nvim_echo( - { { "Error: " .. tostring(setup_err), "ErrorMsg" } }, - true, - {} - ) - vim.cmd("cq") + vim.api.nvim_echo( + { { "Error: " .. tostring(setup_err), "ErrorMsg" } }, + true, + {} + ) + vim.cmd("cq") end local ok_install, install_err = pcall(function() - require("nvim-treesitter").install({ - "c", - "cpp", - "go", - "lua", - "php", - "python", - "typescript", - "javascript", - "java", - "ruby", - "tsx", - "c_sharp", - "vue", - }):wait(300000) + require("nvim-treesitter") + .install({ + "c", + "cpp", + "go", + "lua", + "php", + "python", + "typescript", + "javascript", + "java", + "ruby", + "tsx", + "c_sharp", + "vue", + }) + :wait(300000) end) if not ok_install then - vim.api.nvim_echo({ - { "Error: " .. tostring(install_err), "ErrorMsg" }, - }, true, {}) - vim.cmd("cq") + vim.api.nvim_echo({ + { "Error: " .. tostring(install_err), "ErrorMsg" }, + }, true, {}) + vim.cmd("cq") end local required_parsers = { - c = "c.so", - cpp = "cpp.so", - go = "go.so", - lua = "lua.so", - php = "php.so", - python = "python.so", - typescript = "typescript.so", - javascript = "javascript.so", - java = "java.so", - ruby = "ruby.so", - tsx = "tsx.so", - c_sharp = "c_sharp.so", - vue = "vue.so", + c = "c.so", + cpp = "cpp.so", + go = "go.so", + lua = "lua.so", + php = "php.so", + python = "python.so", + typescript = "typescript.so", + javascript = "javascript.so", + java = "java.so", + ruby = "ruby.so", + tsx = "tsx.so", + c_sharp = "c_sharp.so", + vue = "vue.so", } for lang, filename in pairs(required_parsers) do - local parser_path = install_dir .. "/parser/" .. filename - if not vim.uv.fs_stat(parser_path) then - vim.api.nvim_echo({ - { - "Error: " - .. lang - .. " parser missing after install: " - .. parser_path, - "ErrorMsg", - }, - }, true, {}) - vim.cmd("cq") - end + local parser_path = install_dir .. "/parser/" .. filename + if not vim.uv.fs_stat(parser_path) then + vim.api.nvim_echo({ + { + "Error: " .. lang .. " parser missing after install: " .. parser_path, + "ErrorMsg", + }, + }, true, {}) + vim.cmd("cq") + end end diff --git a/syntax/99prompt.vim b/syntax/99prompt.vim new file mode 100644 index 0000000..31e4b7b --- /dev/null +++ b/syntax/99prompt.vim @@ -0,0 +1,14 @@ +" Syntax file for 99 prompt window +" Highlights #rules in cyan and @files in goldenrod + +if exists("b:current_syntax") + finish +endif + +syntax match 99RuleRef /#\S\+/ +syntax match 99FileRef /@\S\+/ + +highlight default 99RuleRef guifg=#00FFFF ctermfg=cyan +highlight default 99FileRef guifg=#DAA520 ctermfg=178 + +let b:current_syntax = "99prompt" |
