| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504 |
- -- company.lua
- -- ============================================================================
- -- Text completion framework for lumacs.
- -- Similar to Emacs company-mode.
- --
- -- Features:
- -- - Multiple completion backends (words, buffer, path, etc.)
- -- - Completion popup display in echo area
- -- - Configurable triggers and keybindings
- -- ============================================================================
- local company = {}
- -- Configuration
- company.config = {
- enabled = true,
- idle_delay_ms = 500, -- Delay before showing completions
- min_prefix_length = 2, -- Minimum chars before completing
- max_candidates = 10, -- Max candidates to show
- backends = {"buffer", "words"}, -- Active backends in order
- auto_complete = false, -- Auto-show completions while typing
- }
- -- State
- company.active = false
- company.candidates = {}
- company.selected_index = 1
- company.prefix = ""
- company.prefix_start_col = 0
- -- ============================================================================
- -- Backend implementations
- -- ============================================================================
- company.backends = {}
- -- Buffer backend: Complete from words in current buffer
- company.backends.buffer = {
- name = "buffer",
- get_candidates = function(prefix)
- local candidates = {}
- local seen = {}
- local buf = editor.buffer
- local line_count = buf:line_count()
- prefix = prefix:lower()
- for i = 0, line_count - 1 do
- local line = buf:line(i)
- -- Extract words (alphanumeric + underscore)
- for word in line:gmatch("[%w_]+") do
- local word_lower = word:lower()
- if #word >= company.config.min_prefix_length and
- word_lower:sub(1, #prefix) == prefix and
- not seen[word_lower] then
- seen[word_lower] = true
- table.insert(candidates, {
- text = word,
- description = "buffer",
- })
- end
- end
- end
- return candidates
- end
- }
- -- Words backend: Complete from a dictionary of common words
- company.backends.words = {
- name = "words",
- -- Common programming keywords
- keywords = {
- "function", "return", "if", "else", "elseif", "then", "end",
- "for", "while", "do", "repeat", "until", "break", "continue",
- "local", "true", "false", "nil", "and", "or", "not",
- "class", "struct", "enum", "interface", "public", "private", "protected",
- "static", "const", "final", "abstract", "virtual", "override",
- "import", "export", "require", "module", "package",
- "try", "catch", "throw", "finally", "except", "raise",
- "async", "await", "yield", "defer",
- "self", "this", "super", "new", "delete",
- "int", "float", "double", "string", "bool", "void", "char",
- "array", "list", "map", "set", "dict", "table", "vector",
- "print", "println", "printf", "console", "log", "debug", "error",
- },
- get_candidates = function(prefix)
- local candidates = {}
- prefix = prefix:lower()
- for _, word in ipairs(company.backends.words.keywords) do
- if word:sub(1, #prefix) == prefix then
- table.insert(candidates, {
- text = word,
- description = "keyword",
- })
- end
- end
- return candidates
- end
- }
- -- Path backend: Complete file paths
- company.backends.path = {
- name = "path",
- get_candidates = function(prefix)
- local candidates = {}
- -- Only trigger on path-like prefixes
- if not prefix:match("^[%.~/]") and not prefix:match("/") then
- return candidates
- end
- -- Expand ~ to home directory
- local path = prefix:gsub("^~", os.getenv("HOME") or "~")
- -- Get directory and partial filename
- local dir, partial
- if path:match("/$") then
- dir = path
- partial = ""
- else
- dir = path:match("(.+)/") or "."
- partial = path:match("[^/]+$") or ""
- end
- -- List directory contents
- local cmd = string.format("ls -1 '%s' 2>/dev/null", dir)
- local handle = io.popen(cmd)
- if not handle then
- return candidates
- end
- partial = partial:lower()
- for entry in handle:lines() do
- if entry:lower():sub(1, #partial) == partial then
- local full_path
- if prefix:match("^~") then
- full_path = "~" .. dir:sub(#(os.getenv("HOME") or "") + 1) .. "/" .. entry
- else
- full_path = dir .. "/" .. entry
- end
- table.insert(candidates, {
- text = full_path,
- description = "path",
- })
- end
- end
- handle:close()
- return candidates
- end
- }
- -- ============================================================================
- -- Core completion logic
- -- ============================================================================
- -- Get the word prefix at cursor
- function company.get_prefix()
- local buf = editor.buffer
- local cursor = editor.cursor
- local line = buf:line(cursor.line)
- -- Find word start (alphanumeric + underscore, or path chars)
- local col = cursor.column
- local start = col
- while start > 0 do
- local c = line:sub(start, start)
- if c:match("[%w_%.~/]") then
- start = start - 1
- else
- break
- end
- end
- local prefix = line:sub(start + 1, col)
- return prefix, start
- end
- -- Gather candidates from all active backends
- function company.gather_candidates(prefix)
- local all_candidates = {}
- for _, backend_name in ipairs(company.config.backends) do
- local backend = company.backends[backend_name]
- if backend and backend.get_candidates then
- local candidates = backend.get_candidates(prefix)
- for _, c in ipairs(candidates) do
- c.backend = backend_name
- table.insert(all_candidates, c)
- end
- end
- end
- -- Sort by text
- table.sort(all_candidates, function(a, b)
- return a.text < b.text
- end)
- -- Remove duplicates (keep first occurrence)
- local seen = {}
- local unique = {}
- for _, c in ipairs(all_candidates) do
- if not seen[c.text] then
- seen[c.text] = true
- table.insert(unique, c)
- end
- end
- return unique
- end
- -- Format candidates for echo area display
- function company.format_display()
- local lines = {}
- local start_idx = 1
- local end_idx = math.min(#company.candidates, company.config.max_candidates)
- -- Adjust window if selected is out of view
- if company.selected_index > end_idx then
- start_idx = company.selected_index - company.config.max_candidates + 1
- end_idx = company.selected_index
- end
- for i = start_idx, end_idx do
- local c = company.candidates[i]
- local marker = (i == company.selected_index) and ">" or " "
- local line = string.format("%s %s", marker, c.text)
- if c.description then
- line = line .. " (" .. c.description .. ")"
- end
- table.insert(lines, line)
- end
- if #company.candidates > company.config.max_candidates then
- table.insert(lines, string.format(" [%d/%d]", company.selected_index, #company.candidates))
- end
- return lines
- end
- -- Show completion popup
- function company.show()
- if not company.active or #company.candidates == 0 then
- return
- end
- local lines = company.format_display()
- editor:set_echo_area(lines)
- end
- -- Hide completion popup
- function company.hide()
- if company.active then
- company.active = false
- company.candidates = {}
- company.selected_index = 1
- editor:clear_echo_area()
- end
- end
- -- Start completion at point
- function company.complete()
- if not company.config.enabled then
- return
- end
- company.prefix, company.prefix_start_col = company.get_prefix()
- if #company.prefix < company.config.min_prefix_length then
- company.hide()
- return
- end
- company.candidates = company.gather_candidates(company.prefix)
- if #company.candidates == 0 then
- company.hide()
- return
- end
- company.active = true
- company.selected_index = 1
- company.show()
- end
- -- Select next candidate
- function company.select_next()
- if not company.active or #company.candidates == 0 then
- return
- end
- company.selected_index = (company.selected_index % #company.candidates) + 1
- company.show()
- end
- -- Select previous candidate
- function company.select_previous()
- if not company.active or #company.candidates == 0 then
- return
- end
- company.selected_index = ((company.selected_index - 2) % #company.candidates) + 1
- company.show()
- end
- -- Insert selected candidate
- function company.insert_selection()
- if not company.active or #company.candidates == 0 then
- return false
- end
- local candidate = company.candidates[company.selected_index]
- if not candidate then
- return false
- end
- -- Delete the prefix and insert the completion
- local buf = editor.buffer
- local cursor = editor.cursor
- local delete_start = lumacs.Position(cursor.line, company.prefix_start_col)
- local delete_end = cursor
- if delete_start.column < delete_end.column then
- buf:erase(lumacs.Range(delete_start, delete_end))
- end
- -- Insert at the start position
- buf:insert(delete_start, candidate.text)
- -- Move cursor to end of inserted text
- editor.cursor = lumacs.Position(cursor.line, company.prefix_start_col + #candidate.text)
- company.hide()
- return true
- end
- -- Abort completion
- function company.abort()
- company.hide()
- end
- -- ============================================================================
- -- Idle hook for auto-completion
- -- ============================================================================
- function company.check_idle()
- if not company.config.enabled or not company.config.auto_complete then
- return
- end
- -- Only auto-complete if we've been idle long enough
- local idle_ms = editor:idle_time_ms()
- if idle_ms < company.config.idle_delay_ms then
- return
- end
- -- Don't auto-complete if minibuffer is active
- if editor.minibuffer_manager and editor.minibuffer_manager:is_active() then
- return
- end
- -- Don't auto-complete if already showing
- if company.active then
- return
- end
- -- Try to complete
- company.complete()
- end
- -- ============================================================================
- -- Commands
- -- ============================================================================
- editor:register_command("company-complete", "Start completion at point", function(args)
- company.complete()
- return {success = true}
- end, {"complete"})
- editor:register_command("company-select-next", "Select next completion candidate", function(args)
- company.select_next()
- return {success = true}
- end)
- editor:register_command("company-select-previous", "Select previous completion candidate", function(args)
- company.select_previous()
- return {success = true}
- end)
- editor:register_command("company-complete-selection", "Insert selected completion", function(args)
- if company.insert_selection() then
- return {success = true, message = "Completion inserted"}
- else
- return {success = false, message = "No completion active"}
- end
- end)
- editor:register_command("company-abort", "Cancel completion", function(args)
- company.abort()
- return {success = true}
- end)
- editor:register_command("company-mode", "Toggle company-mode", function(args)
- company.config.enabled = not company.config.enabled
- if company.config.enabled then
- editor:message("company-mode enabled")
- else
- company.hide()
- editor:message("company-mode disabled")
- end
- return {success = true}
- end)
- editor:register_command("company-auto-complete", "Toggle auto-completion", function(args)
- company.config.auto_complete = not company.config.auto_complete
- if company.config.auto_complete then
- editor:message("Auto-completion enabled")
- else
- editor:message("Auto-completion disabled")
- end
- return {success = true}
- end)
- editor:register_command("company-backends", "List or set completion backends", function(args)
- if #args == 0 then
- return {success = true, message = "Backends: " .. table.concat(company.config.backends, ", ")}
- end
- -- Parse comma-separated list
- local backends = {}
- for backend in args[1]:gmatch("[^,]+") do
- backend = backend:match("^%s*(.-)%s*$") -- trim
- if company.backends[backend] then
- table.insert(backends, backend)
- else
- return {success = false, message = "Unknown backend: " .. backend}
- end
- end
- company.config.backends = backends
- return {success = true, message = "Backends set to: " .. table.concat(backends, ", ")}
- end, {}, true, "s")
- -- ============================================================================
- -- Keybindings
- -- ============================================================================
- editor:bind_key("M-Tab", "company-complete", "Complete at point")
- editor:bind_key("C-n", function()
- if company.active then
- company.select_next()
- else
- editor:move_down()
- end
- end, "Next line or completion")
- editor:bind_key("C-p", function()
- if company.active then
- company.select_previous()
- else
- editor:move_up()
- end
- end, "Previous line or completion")
- -- Note: These override default behavior when completion is active
- -- Tab to complete selection when active
- editor:bind_key("Tab", function()
- if company.active then
- company.insert_selection()
- else
- -- Default tab behavior (insert spaces)
- editor.buffer:insert(editor.cursor, " ")
- for i = 1, 4 do editor:move_right() end
- end
- end, "Tab or complete")
- -- Escape to abort
- editor:bind_key("Escape", function()
- if company.active then
- company.abort()
- else
- editor:execute_command("keyboard-quit", {})
- end
- end, "Abort or quit")
- -- Register idle hook
- lumacs.company_check_idle = company.check_idle
- -- Store module
- lumacs.company = company
- print("[company-mode] Package loaded")
- return company
|