-- 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