diff --git a/README.md b/README.md index d2bcee5..a2686a6 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ hash-bang line (`#!/usr/bin/lua`). Putting this snippet to `.emacs` should be en - documentation lookup (using online/offline reference manual, e.g. [string.find](http://www.lua.org/manual/5.1/manual.html#pdf-string.find)) - [imenu](http://www.gnu.org/software/emacs/manual/html_node/emacs/Imenu.html) integration - [HideShow](http://www.gnu.org/software/emacs/manual/html_node/emacs/Hideshow.html) integration +- using `completion-at-point` queries active Lua subprocess for completion targets ## CUSTOMIZATION @@ -56,6 +57,7 @@ The following variables are available for customization (see more via `M-x custo - Var `lua-mode-hook`: list of functions to execute when lua-mode is initialized - Var `lua-documentation-url` (default `"http://www.lua.org/manual/5.1/manual.html#pdf-"`): base URL for documentation lookup - Var `lua-documentation-function` (default `browse-url`): function used to show documentation (`eww` is a viable alternative for Emacs 25) +- Var `lua-local-require-completions` (default `nil`): set to `t` to have completion attempt to load local libraries for current file for completion targets; can cause unexpected side-effects ## LUA SUBPROCESS CREATION diff --git a/lua-mode.el b/lua-mode.el index f35d964..28ee40e 100644 --- a/lua-mode.el +++ b/lua-mode.el @@ -769,6 +769,9 @@ Groups 6-9 can be used in any of argument regexps." (setq mode-popup-menu (cons (concat mode-name " Mode Commands") lua-emacs-menu))) + (make-local-variable 'completion-at-point-functions) + (add-to-list 'completion-at-point-functions 'lua-complete-function) + ;; hideshow setup (unless (assq 'lua-mode hs-special-modes-alist) (add-to-list 'hs-special-modes-alist @@ -1825,6 +1828,146 @@ If `lua-process' is nil or dead, start a new process first." (lua-send-region start end) (error "Not on a function definition"))))) +(defun lua-completion-trim-input (i) + (format "'%s'," (replace-regexp-in-string "[[:space:]]" "" i))) + + +(defun lua-completion-string-for (expr libs locals out-file) + "Construct a string of Lua code to write completions to `out-file'. + +The `expr' arg should be the input string (which may contain dots +for table lookup), and `libs' should be a list of the format returned +by `lua-local-libs', or nil." + (mapconcat 'identity + `("do" + "local clone = function(t)" + " local n = {} for k,v in pairs(t) do n[k] = v end return n" + "end" + + ;; Completion context starts as just a clone of _G. + "local top_ctx = clone(_G)" + ;; But then we take all matches of local x = require y from + ;; our code buffer and stuff them into the completion table too. + ,@(mapcar (lambda (l) + (format "top_ctx['%s'] = require(%s)" + (car l) (cadr l))) libs) + ;; Top-level locals should also provide completion candidates. + ;; We don't know anything about what they contain, so just + ;; initialize them to an empty table for 1-level completion. + ,@(mapcar (apply-partially 'format "top_ctx['%s'] = {}") locals) + + ;; recursively delve into context based on input_parts + "local function cpl_for(input_parts, ctx)" + " if type(ctx) ~= \"table\" then return {} end" + " if #input_parts == 0 and ctx ~= top_ctx then" + " return ctx" + " elseif #input_parts == 1 then" ; the last segment of input + " local matches = {}" + " for k in pairs(ctx) do" + " if k:find('^' .. input_parts[1]) then" + " table.insert(matches, k)" + " end" + " end" + " return matches" + " else" ; more segments of input remain; descend into ctx table + " local token1 = table.remove(input_parts, 1)" + " return cpl_for(input_parts, ctx[token1])" + " end" + "end" + + ;; this is a table of the segments of the input + "local input = {" + ,@(mapcar 'lua-completion-trim-input (split-string expr "\\.")) "}" + ;; spit it out to a file; lua-mode can't send data back to emacs + ,(format "local f = io.open('%s', 'w')" out-file) + "for _,l in ipairs(cpl_for(input, top_ctx)) do" + " f:write(l .. string.char(10))" + "end" + "f:close()" + "end") " ")) + +(defvar lua-local-require-completions nil + "During completion, scan file for local require calls for context. + +Defaults to nil because this will cause code to be loaded during completion. +Loading arbitrary code can have unexpected side-effects, so use with caution.") + +(defvar lua-local-require-regexp + "^local\\s-+\\([^ \n]+\\)\\s-*=\\s-*require[( ]+\\([^ )\n]+\\)" + "A regexp to match lines where a library is required and put in a local.") + +(defvar lua-top-level-local-regexp + "^local\\s-+\\([^ \n]+\\)\\s-*=" + "A regexp to match top-level local definitions") + +(defun lua-local-libs () + "Find all modules loaded with require which are stored in locals. + +Returns a list of lists where the car is the local name and the cadr +is the string that is passed to require." + (save-excursion + (when lua-local-require-completions + (let ((libs nil)) + (goto-char (point-min)) + ;; find each match of "local x = require y" and save for later + (while (lua-find-regexp 'forward lua-local-require-regexp) + (add-to-list 'libs (list (match-string-no-properties 1) + (match-string-no-properties 2)))) + libs)))) + +(defun lua-top-level-locals (lib-names) + "Return a list of all top-level locals in the file for completion targets." + (save-excursion + (let ((locals nil)) + (goto-char (point-min)) + (while (lua-find-regexp 'forward lua-top-level-local-regexp) + (let ((local (match-string-no-properties 1))) + (when (not (member local lib-names)) + (add-to-list 'locals local)))) + locals))) + +(defun lua-start-of-expr () + "Search backwards to find the beginning of the current expression. + +This is distinct from `backward-sexp' which treats . and : as a separator." + (save-excursion + (backward-sexp) + (let ((bos (point))) + (when (> (point) 1) (backward-char)) + (when (string-match "[[:space:]]" (thing-at-point 'char)) + (search-backward-regexp "[^\n\s-]" nil t) + (when (string= (thing-at-point 'char) "\n") + (backward-char))) + (if (member (thing-at-point 'char) '(":" ".")) + (lua-start-of-expr) + bos)))) + +(defun lua-complete-function () + "Completion function for `completion-at-point-functions'. + +Queries current lua subprocess for possible completions." + (let* ((start-of-expr (lua-start-of-expr)) + (expr (buffer-substring-no-properties start-of-expr (point))) + (expr (car (last (split-string expr "\n")))) ; avoid multi-line input + (libs (lua-local-libs)) + (locals (lua-top-level-locals (mapcar 'car libs))) + (file (make-temp-file "lua-completions-"))) + (lua-send-string (lua-completion-string-for expr libs locals file)) + (sit-for 0.1) + (unwind-protect + (list (save-excursion + (when (symbol-at-point) (backward-sexp)) + (point)) + (point) + (when (file-exists-p file) + (with-temp-buffer + (insert-file-contents file) + (butlast (split-string + (buffer-substring-no-properties + (point-min) (point-max)) + "\n"))))) + (delete-file file)))) + (defun lua-maybe-skip-shebang-line (start) "Skip shebang (#!/path/to/interpreter/) line at beginning of buffer. diff --git a/test/file.lua b/test/file.lua new file mode 100644 index 0000000..9cfcf88 --- /dev/null +++ b/test/file.lua @@ -0,0 +1 @@ +return {abc = 123, def = 456} diff --git a/test/test-completion.el b/test/test-completion.el new file mode 100644 index 0000000..434a0c5 --- /dev/null +++ b/test/test-completion.el @@ -0,0 +1,56 @@ +;; -*- flycheck-disabled-checkers: (emacs-lisp-checkdoc) -*- +(load (concat (file-name-directory (or load-file-name (buffer-file-name) + default-directory)) + "utils.el") nil 'nomessage 'nosuffix) +(require 'cl-lib) + +(describe "Test lua-complete-function" + (it "completes top-level globals" + (with-lua-buffer + (insert "tabl") + (run-lua) + (completion-at-point) + (expect (buffer-string) :to-equal "table"))) + (it "completes nested globals" + (with-lua-buffer + (insert "table.ins") + (run-lua) + (completion-at-point) + (expect (buffer-string) :to-equal "table.insert"))) + (it "completes nested globals with spaces" + (with-lua-buffer + (insert "table. ins") + (run-lua) + (completion-at-point) + (expect (buffer-string) :to-equal "table. insert"))) + (it "completes nested globals inside function call" + (with-lua-buffer + (insert "print(table.con)") + (backward-char) + (run-lua) + (completion-at-point) + (expect (buffer-string) :to-equal "print(table.concat)"))) + (it "completes nested globals with newline" + (with-lua-buffer + (insert "table.\n ins") + (run-lua) + (completion-at-point) + (expect (buffer-string) :to-equal "table.\n insert")))) + +(describe "Test lua-complete-function with lua-local-require-regexp" + (it "completes locally-required libraries" + (with-lua-buffer + (insert "local xyz = require('test.file')\n") + (insert "xy") + (let ((lua-local-require-completions t)) + (run-lua) + (completion-at-point)) + (expect (thing-at-point 'line) :to-equal "xyz"))) + (it "completes values nested in locally-required libraries" + (with-lua-buffer + (insert "local xyz = require('test.file')\n") + (insert "xyz.ab") + (let ((lua-local-require-completions t)) + (run-lua) + (completion-at-point)) + (expect (thing-at-point 'line) :to-equal "xyz.abc"))))