init.lua 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. -- company.lua
  2. -- ============================================================================
  3. -- Text completion framework for lumacs.
  4. -- Similar to Emacs company-mode.
  5. --
  6. -- Features:
  7. -- - Multiple completion backends (words, buffer, path, etc.)
  8. -- - Completion popup display in echo area
  9. -- - Configurable triggers and keybindings
  10. -- ============================================================================
  11. local company = {}
  12. -- Configuration
  13. company.config = {
  14. enabled = true,
  15. idle_delay_ms = 500, -- Delay before showing completions
  16. min_prefix_length = 2, -- Minimum chars before completing
  17. max_candidates = 10, -- Max candidates to show
  18. backends = {"buffer", "words"}, -- Active backends in order
  19. auto_complete = false, -- Auto-show completions while typing
  20. }
  21. -- State
  22. company.active = false
  23. company.candidates = {}
  24. company.selected_index = 1
  25. company.prefix = ""
  26. company.prefix_start_col = 0
  27. -- ============================================================================
  28. -- Backend implementations
  29. -- ============================================================================
  30. company.backends = {}
  31. -- Buffer backend: Complete from words in current buffer
  32. company.backends.buffer = {
  33. name = "buffer",
  34. get_candidates = function(prefix)
  35. local candidates = {}
  36. local seen = {}
  37. local buf = editor.buffer
  38. local line_count = buf:line_count()
  39. prefix = prefix:lower()
  40. for i = 0, line_count - 1 do
  41. local line = buf:line(i)
  42. -- Extract words (alphanumeric + underscore)
  43. for word in line:gmatch("[%w_]+") do
  44. local word_lower = word:lower()
  45. if #word >= company.config.min_prefix_length and
  46. word_lower:sub(1, #prefix) == prefix and
  47. not seen[word_lower] then
  48. seen[word_lower] = true
  49. table.insert(candidates, {
  50. text = word,
  51. description = "buffer",
  52. })
  53. end
  54. end
  55. end
  56. return candidates
  57. end
  58. }
  59. -- Words backend: Complete from a dictionary of common words
  60. company.backends.words = {
  61. name = "words",
  62. -- Common programming keywords
  63. keywords = {
  64. "function", "return", "if", "else", "elseif", "then", "end",
  65. "for", "while", "do", "repeat", "until", "break", "continue",
  66. "local", "true", "false", "nil", "and", "or", "not",
  67. "class", "struct", "enum", "interface", "public", "private", "protected",
  68. "static", "const", "final", "abstract", "virtual", "override",
  69. "import", "export", "require", "module", "package",
  70. "try", "catch", "throw", "finally", "except", "raise",
  71. "async", "await", "yield", "defer",
  72. "self", "this", "super", "new", "delete",
  73. "int", "float", "double", "string", "bool", "void", "char",
  74. "array", "list", "map", "set", "dict", "table", "vector",
  75. "print", "println", "printf", "console", "log", "debug", "error",
  76. },
  77. get_candidates = function(prefix)
  78. local candidates = {}
  79. prefix = prefix:lower()
  80. for _, word in ipairs(company.backends.words.keywords) do
  81. if word:sub(1, #prefix) == prefix then
  82. table.insert(candidates, {
  83. text = word,
  84. description = "keyword",
  85. })
  86. end
  87. end
  88. return candidates
  89. end
  90. }
  91. -- Path backend: Complete file paths
  92. company.backends.path = {
  93. name = "path",
  94. get_candidates = function(prefix)
  95. local candidates = {}
  96. -- Only trigger on path-like prefixes
  97. if not prefix:match("^[%.~/]") and not prefix:match("/") then
  98. return candidates
  99. end
  100. -- Expand ~ to home directory
  101. local path = prefix:gsub("^~", os.getenv("HOME") or "~")
  102. -- Get directory and partial filename
  103. local dir, partial
  104. if path:match("/$") then
  105. dir = path
  106. partial = ""
  107. else
  108. dir = path:match("(.+)/") or "."
  109. partial = path:match("[^/]+$") or ""
  110. end
  111. -- List directory contents
  112. local cmd = string.format("ls -1 '%s' 2>/dev/null", dir)
  113. local handle = io.popen(cmd)
  114. if not handle then
  115. return candidates
  116. end
  117. partial = partial:lower()
  118. for entry in handle:lines() do
  119. if entry:lower():sub(1, #partial) == partial then
  120. local full_path
  121. if prefix:match("^~") then
  122. full_path = "~" .. dir:sub(#(os.getenv("HOME") or "") + 1) .. "/" .. entry
  123. else
  124. full_path = dir .. "/" .. entry
  125. end
  126. table.insert(candidates, {
  127. text = full_path,
  128. description = "path",
  129. })
  130. end
  131. end
  132. handle:close()
  133. return candidates
  134. end
  135. }
  136. -- ============================================================================
  137. -- Core completion logic
  138. -- ============================================================================
  139. -- Get the word prefix at cursor
  140. function company.get_prefix()
  141. local buf = editor.buffer
  142. local cursor = editor.cursor
  143. local line = buf:line(cursor.line)
  144. -- Find word start (alphanumeric + underscore, or path chars)
  145. local col = cursor.column
  146. local start = col
  147. while start > 0 do
  148. local c = line:sub(start, start)
  149. if c:match("[%w_%.~/]") then
  150. start = start - 1
  151. else
  152. break
  153. end
  154. end
  155. local prefix = line:sub(start + 1, col)
  156. return prefix, start
  157. end
  158. -- Gather candidates from all active backends
  159. function company.gather_candidates(prefix)
  160. local all_candidates = {}
  161. for _, backend_name in ipairs(company.config.backends) do
  162. local backend = company.backends[backend_name]
  163. if backend and backend.get_candidates then
  164. local candidates = backend.get_candidates(prefix)
  165. for _, c in ipairs(candidates) do
  166. c.backend = backend_name
  167. table.insert(all_candidates, c)
  168. end
  169. end
  170. end
  171. -- Sort by text
  172. table.sort(all_candidates, function(a, b)
  173. return a.text < b.text
  174. end)
  175. -- Remove duplicates (keep first occurrence)
  176. local seen = {}
  177. local unique = {}
  178. for _, c in ipairs(all_candidates) do
  179. if not seen[c.text] then
  180. seen[c.text] = true
  181. table.insert(unique, c)
  182. end
  183. end
  184. return unique
  185. end
  186. -- Format candidates for echo area display
  187. function company.format_display()
  188. local lines = {}
  189. local start_idx = 1
  190. local end_idx = math.min(#company.candidates, company.config.max_candidates)
  191. -- Adjust window if selected is out of view
  192. if company.selected_index > end_idx then
  193. start_idx = company.selected_index - company.config.max_candidates + 1
  194. end_idx = company.selected_index
  195. end
  196. for i = start_idx, end_idx do
  197. local c = company.candidates[i]
  198. local marker = (i == company.selected_index) and ">" or " "
  199. local line = string.format("%s %s", marker, c.text)
  200. if c.description then
  201. line = line .. " (" .. c.description .. ")"
  202. end
  203. table.insert(lines, line)
  204. end
  205. if #company.candidates > company.config.max_candidates then
  206. table.insert(lines, string.format(" [%d/%d]", company.selected_index, #company.candidates))
  207. end
  208. return lines
  209. end
  210. -- Show completion popup
  211. function company.show()
  212. if not company.active or #company.candidates == 0 then
  213. return
  214. end
  215. local lines = company.format_display()
  216. editor:set_echo_area(lines)
  217. end
  218. -- Hide completion popup
  219. function company.hide()
  220. if company.active then
  221. company.active = false
  222. company.candidates = {}
  223. company.selected_index = 1
  224. editor:clear_echo_area()
  225. end
  226. end
  227. -- Start completion at point
  228. function company.complete()
  229. if not company.config.enabled then
  230. return
  231. end
  232. company.prefix, company.prefix_start_col = company.get_prefix()
  233. if #company.prefix < company.config.min_prefix_length then
  234. company.hide()
  235. return
  236. end
  237. company.candidates = company.gather_candidates(company.prefix)
  238. if #company.candidates == 0 then
  239. company.hide()
  240. return
  241. end
  242. company.active = true
  243. company.selected_index = 1
  244. company.show()
  245. end
  246. -- Select next candidate
  247. function company.select_next()
  248. if not company.active or #company.candidates == 0 then
  249. return
  250. end
  251. company.selected_index = (company.selected_index % #company.candidates) + 1
  252. company.show()
  253. end
  254. -- Select previous candidate
  255. function company.select_previous()
  256. if not company.active or #company.candidates == 0 then
  257. return
  258. end
  259. company.selected_index = ((company.selected_index - 2) % #company.candidates) + 1
  260. company.show()
  261. end
  262. -- Insert selected candidate
  263. function company.insert_selection()
  264. if not company.active or #company.candidates == 0 then
  265. return false
  266. end
  267. local candidate = company.candidates[company.selected_index]
  268. if not candidate then
  269. return false
  270. end
  271. -- Delete the prefix and insert the completion
  272. local buf = editor.buffer
  273. local cursor = editor.cursor
  274. local delete_start = lumacs.Position(cursor.line, company.prefix_start_col)
  275. local delete_end = cursor
  276. if delete_start.column < delete_end.column then
  277. buf:erase(lumacs.Range(delete_start, delete_end))
  278. end
  279. -- Insert at the start position
  280. buf:insert(delete_start, candidate.text)
  281. -- Move cursor to end of inserted text
  282. editor.cursor = lumacs.Position(cursor.line, company.prefix_start_col + #candidate.text)
  283. company.hide()
  284. return true
  285. end
  286. -- Abort completion
  287. function company.abort()
  288. company.hide()
  289. end
  290. -- ============================================================================
  291. -- Idle hook for auto-completion
  292. -- ============================================================================
  293. function company.check_idle()
  294. if not company.config.enabled or not company.config.auto_complete then
  295. return
  296. end
  297. -- Only auto-complete if we've been idle long enough
  298. local idle_ms = editor:idle_time_ms()
  299. if idle_ms < company.config.idle_delay_ms then
  300. return
  301. end
  302. -- Don't auto-complete if minibuffer is active
  303. if editor.minibuffer_manager and editor.minibuffer_manager:is_active() then
  304. return
  305. end
  306. -- Don't auto-complete if already showing
  307. if company.active then
  308. return
  309. end
  310. -- Try to complete
  311. company.complete()
  312. end
  313. -- ============================================================================
  314. -- Commands
  315. -- ============================================================================
  316. editor:register_command("company-complete", "Start completion at point", function(args)
  317. company.complete()
  318. return {success = true}
  319. end, {"complete"})
  320. editor:register_command("company-select-next", "Select next completion candidate", function(args)
  321. company.select_next()
  322. return {success = true}
  323. end)
  324. editor:register_command("company-select-previous", "Select previous completion candidate", function(args)
  325. company.select_previous()
  326. return {success = true}
  327. end)
  328. editor:register_command("company-complete-selection", "Insert selected completion", function(args)
  329. if company.insert_selection() then
  330. return {success = true, message = "Completion inserted"}
  331. else
  332. return {success = false, message = "No completion active"}
  333. end
  334. end)
  335. editor:register_command("company-abort", "Cancel completion", function(args)
  336. company.abort()
  337. return {success = true}
  338. end)
  339. editor:register_command("company-mode", "Toggle company-mode", function(args)
  340. company.config.enabled = not company.config.enabled
  341. if company.config.enabled then
  342. editor:message("company-mode enabled")
  343. else
  344. company.hide()
  345. editor:message("company-mode disabled")
  346. end
  347. return {success = true}
  348. end)
  349. editor:register_command("company-auto-complete", "Toggle auto-completion", function(args)
  350. company.config.auto_complete = not company.config.auto_complete
  351. if company.config.auto_complete then
  352. editor:message("Auto-completion enabled")
  353. else
  354. editor:message("Auto-completion disabled")
  355. end
  356. return {success = true}
  357. end)
  358. editor:register_command("company-backends", "List or set completion backends", function(args)
  359. if #args == 0 then
  360. return {success = true, message = "Backends: " .. table.concat(company.config.backends, ", ")}
  361. end
  362. -- Parse comma-separated list
  363. local backends = {}
  364. for backend in args[1]:gmatch("[^,]+") do
  365. backend = backend:match("^%s*(.-)%s*$") -- trim
  366. if company.backends[backend] then
  367. table.insert(backends, backend)
  368. else
  369. return {success = false, message = "Unknown backend: " .. backend}
  370. end
  371. end
  372. company.config.backends = backends
  373. return {success = true, message = "Backends set to: " .. table.concat(backends, ", ")}
  374. end, {}, true, "s")
  375. -- ============================================================================
  376. -- Keybindings
  377. -- ============================================================================
  378. editor:bind_key("M-Tab", "company-complete", "Complete at point")
  379. editor:bind_key("C-n", function()
  380. if company.active then
  381. company.select_next()
  382. else
  383. editor:move_down()
  384. end
  385. end, "Next line or completion")
  386. editor:bind_key("C-p", function()
  387. if company.active then
  388. company.select_previous()
  389. else
  390. editor:move_up()
  391. end
  392. end, "Previous line or completion")
  393. -- Note: These override default behavior when completion is active
  394. -- Tab to complete selection when active
  395. editor:bind_key("Tab", function()
  396. if company.active then
  397. company.insert_selection()
  398. else
  399. -- Default tab behavior (insert spaces)
  400. editor.buffer:insert(editor.cursor, " ")
  401. for i = 1, 4 do editor:move_right() end
  402. end
  403. end, "Tab or complete")
  404. -- Escape to abort
  405. editor:bind_key("Escape", function()
  406. if company.active then
  407. company.abort()
  408. else
  409. editor:execute_command("keyboard-quit", {})
  410. end
  411. end, "Abort or quit")
  412. -- Register idle hook
  413. lumacs.company_check_idle = company.check_idle
  414. -- Store module
  415. lumacs.company = company
  416. print("[company-mode] Package loaded")
  417. return company