|
|
@@ -0,0 +1,504 @@
|
|
|
+-- 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
|