├── LuaAutocomplete.py ├── license.txt ├── locals.py ├── lua-autocomplete.sublime-project └── readme.md /LuaAutocomplete.py: -------------------------------------------------------------------------------- 1 | 2 | import sublime, sublime_plugin 3 | import re, os, itertools 4 | from LuaAutocomplete.locals import LocalsFinder 5 | 6 | class LocalsAutocomplete(sublime_plugin.EventListener): 7 | @staticmethod 8 | def can_local_autocomplete(view, location): 9 | """ 10 | Returns true if locals autocompetion makes sense in the specified location (ex. its not indexing a variable, in a string, ...) 11 | """ 12 | pos = view.find_by_class(location, False, sublime.CLASS_WORD_START) 13 | if pos == 0: 14 | return True 15 | 16 | scope_name = view.scope_name(location) 17 | if "string." in scope_name or "comment." in scope_name: 18 | # In a string or comment 19 | return False 20 | 21 | if "parameter" in scope_name: 22 | # Specifying parameters 23 | return False 24 | 25 | char = view.substr(pos-1) 26 | if char == "." or char == ":": 27 | # Indexing a value 28 | return False 29 | 30 | return True 31 | 32 | def on_query_completions(self, view, prefix, locations): 33 | if view.settings().get("syntax") != "Packages/Lua/Lua.tmLanguage": 34 | # Not Lua, don't do anything. 35 | return 36 | 37 | location = locations[0] # TODO: Better multiselect behavior? 38 | 39 | if not LocalsAutocomplete.can_local_autocomplete(view, location): 40 | return 41 | 42 | src = view.substr(sublime.Region(0, view.size())) 43 | 44 | localsfinder = LocalsFinder(src) 45 | varz = localsfinder.run(location) 46 | 47 | return [(name+"\t"+data.vartype,name) for name, data in varz.items()], sublime.INHIBIT_WORD_COMPLETIONS 48 | 49 | class RequireAutocomplete(sublime_plugin.EventListener): 50 | 51 | @staticmethod 52 | def filter_lua_files(filenames): 53 | for f in filenames: 54 | fname, ext = os.path.splitext(f) 55 | if ext == ".lua" or ext == ".luac": 56 | yield fname 57 | 58 | def on_query_completions(self, view, prefix, locations): 59 | if view.settings().get("syntax") != "Packages/Lua/Lua.tmLanguage": 60 | # Not Lua, don't do anything. 61 | return 62 | 63 | proj_file = view.window().project_file_name() 64 | if not proj_file: 65 | # No project 66 | return 67 | 68 | location = locations[0] 69 | src = view.substr(sublime.Region(0, location)) 70 | 71 | match = re.search(r"""require\s*\(?\s*["']([^"]*)$""", src) 72 | if not match: 73 | return 74 | 75 | module_path = match.group(1).split(".") 76 | 77 | results = [] 78 | proj_dir = os.path.dirname(proj_file) 79 | 80 | for proj_subdir in view.window().project_data()["folders"]: 81 | proj_subdir = proj_subdir["path"] 82 | cur_path = os.path.join(proj_dir, proj_subdir, *(module_path[:-1])) 83 | print("curpath:", cur_path) 84 | if not os.path.exists(cur_path) or not os.path.isdir(cur_path): 85 | continue 86 | 87 | _, dirs, files = next(os.walk(cur_path)) # walk splits directories and regular files for us 88 | 89 | results.extend(map(lambda x: (x+"\tsubdirectory", x+"."), dirs)) 90 | results.extend(map(lambda x: (x+"\tmodule", x), RequireAutocomplete.filter_lua_files(files))) 91 | 92 | return results, sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS 93 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /locals.py: -------------------------------------------------------------------------------- 1 | 2 | from collections import OrderedDict, namedtuple 3 | from copy import copy 4 | import logging 5 | import re 6 | 7 | logger = logging.getLogger("LuaAutocomplete.locals") 8 | 9 | localvar_re = re.compile(r"(?:[a-zA-Z_][a-zA-Z0-9_]*|\.\.\.)") 10 | 11 | class StopParsing(Exception): 12 | pass 13 | 14 | # Holds info about a variable. 15 | # vartype: Semantic info about the origins of a variable, ex. if it's a local var, a for loop index, an upvalue, ... 16 | VarInfo = namedtuple("VarInfo", ["vartype"]) 17 | 18 | class LocalsFinder: 19 | """ 20 | Parses a Lua file, looking for local variables that are in a certain scope. 21 | """ 22 | 23 | # Both patterns and matches need to be ordered, so that `longcomment` is tried first before `comment` 24 | patterns = OrderedDict([ 25 | ("for_incremental", re.compile(r"\bfor\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=.*?\bdo\b", re.S)), 26 | ("for_iterator", re.compile(r"\bfor\s*((?:[a-zA-Z_][a-zA-Z0-9_]*|,\s*)+)\s*in\b.*?\bdo\b", re.S)), 27 | ("local_function", re.compile(r"\blocal\s+function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(((?:[a-zA-Z_][a-zA-Z0-9_]*|\.\.\.|,\s*)*)\)")), 28 | ("function", re.compile(r"\bfunction(?:\s+[a-zA-Z0-9._]*)?\(((?:[a-zA-Z_][a-zA-Z0-9_]*|\.\.\.|,\s*)*)\)")), 29 | ("method", re.compile(r"\bfunction\s+[a-zA-Z0-9._]+:[a-zA-Z0-9_]+\(((?:[a-zA-Z_][a-zA-Z0-9_]*|\.\.\.|,\s*)*)\)")), 30 | ("block_start", re.compile(r"\b(?:do|then|repeat)\b")), # Matches while loops, incomplete for loops, and `do ... end` blocks 31 | ("block_end", re.compile(r"\b(?:end|until)\b")), 32 | ("locals", re.compile(r"\blocal\s+((?:[a-zA-Z_][a-zA-Z0-9_]*|,\s*)+)\b")), 33 | ("longcomment", re.compile(r"\-\-\[(=*)\[")), 34 | ("comment", re.compile(r"\-\-")), 35 | ("string", re.compile(r"""(?:"|')""")), 36 | ("longstring", re.compile(r"\[(=*)\[")), 37 | ]) 38 | 39 | def __init__(self, code): 40 | """ 41 | Creates a new parser. 42 | """ 43 | self.code = code 44 | 45 | def run(self, cursor): 46 | """ 47 | Runs the parser. cursor is the location of the scope. 48 | """ 49 | self.matches = OrderedDict() 50 | self.scope_stack = [{}] 51 | 52 | self.setup_initial_matches() 53 | 54 | try: 55 | current_pos = 0 56 | while True: 57 | name, match = self.rematch(current_pos) 58 | if not match: 59 | break 60 | 61 | if match.start() >= cursor: 62 | break 63 | 64 | current_pos = self.dispatch(name, match) 65 | except StopParsing: 66 | pass 67 | 68 | curscope = self.scope_stack[-1] 69 | del self.matches 70 | del self.scope_stack 71 | return curscope 72 | 73 | def setup_initial_matches(self): 74 | for name, regex in self.patterns.items(): 75 | self.matches[name] = regex.search(self.code) 76 | 77 | def rematch(self, pos): 78 | best_name, best_match = None, None 79 | 80 | for name, regex in self.patterns.items(): 81 | match = self.matches[name] 82 | 83 | if not match: 84 | # Previous try didn't find anything. Trying now won't find anything either. 85 | continue 86 | 87 | # If the new position is less than the first match, the regex doesn't need to be re-ran. 88 | if pos > match.start(): 89 | match = regex.search(self.code, pos) 90 | self.matches[name] = match 91 | 92 | # Find first match 93 | if match and (not best_match or match.start() < best_match.start()): 94 | best_name = name 95 | best_match = match 96 | return best_name, best_match 97 | 98 | def dispatch(self, name, match): 99 | logger.debug("Matched %s at char %s", name, match.start()) 100 | return getattr(self, "handle_"+name)(match) 101 | 102 | def push_scope(self, is_function=False): 103 | if not is_function: 104 | self.scope_stack.append(self.scope_stack[-1].copy()) 105 | else: 106 | newscope = {} 107 | for k, v in self.scope_stack[-1].items(): 108 | v2 = VarInfo(vartype="upvalue") 109 | newscope[k] = v2 110 | self.scope_stack.append(newscope) 111 | 112 | def pop_scope(self): 113 | if len(self.scope_stack) == 1: 114 | logging.debug("Scope stack underflow; probably an excess `end`") 115 | # TODO: Can we handle excessive ends better? 116 | else: 117 | self.scope_stack.pop() 118 | 119 | def add_var(self, name, **kwargs): 120 | self.scope_stack[-1][name] = VarInfo(**kwargs) 121 | 122 | def add_vars(self, vars, **kwargs): 123 | info = VarInfo(**kwargs) 124 | for name in vars: 125 | self.scope_stack[-1][name] = info 126 | 127 | ######################################################################### 128 | 129 | def handle_for_incremental(self, match): 130 | self.push_scope() 131 | 132 | self.add_var(match.group(1), vartype="for index") 133 | return match.end() 134 | 135 | def handle_for_iterator(self, match): 136 | self.push_scope() 137 | 138 | the_locals = localvar_re.findall(match.group(1)) 139 | self.add_vars(the_locals, vartype="for index") 140 | return match.end() 141 | 142 | def handle_local_function(self, match): 143 | self.add_var(match.group(1), vartype="local") 144 | 145 | self.push_scope(is_function=True) 146 | arguments = localvar_re.findall(match.group(2)) 147 | self.add_vars(arguments, vartype="parameter") 148 | return match.end() 149 | 150 | def handle_function(self, match): 151 | self.push_scope(is_function=True) 152 | arguments = localvar_re.findall(match.group(1)) 153 | self.add_vars(arguments, vartype="parameter") 154 | return match.end() 155 | 156 | def handle_method(self, match): 157 | self.push_scope(is_function=True) 158 | arguments = localvar_re.findall(match.group(1)) 159 | self.add_var("self", vartype="self") 160 | self.add_vars(arguments, vartype="parameter") 161 | return match.end() 162 | 163 | def handle_block_start(self, match): 164 | self.push_scope() 165 | return match.end() 166 | 167 | def handle_block_end(self, match): 168 | self.pop_scope() 169 | return match.end() 170 | 171 | def handle_locals(self, match): 172 | the_locals = localvar_re.findall(match.group(1)) 173 | self.add_vars(the_locals, vartype="local") 174 | return match.end() 175 | 176 | def handle_comment(self, match): 177 | line_end = self.code.find("\n", match.end()) 178 | if line_end == -1: 179 | raise StopParsing() # EOF 180 | return line_end+1 181 | 182 | def handle_longcomment(self, match): 183 | end_str = "]" + match.group(1) + "]" # Match number of equals signs 184 | comment_end = self.code.find(end_str, match.end()) 185 | if comment_end == -1: 186 | raise StopParsing() # EOF 187 | return comment_end+len(end_str) 188 | 189 | def handle_string(self, match): 190 | str_char = match.group(0) # single or double quotes? 191 | str_end = match.end() 192 | 193 | while self.code[str_end] != str_char or self.code[str_end-1] == "\\": # Keep looking for unescaped terminator 194 | str_end = self.code.find(str_char, str_end+1) 195 | if str_end == -1: 196 | raise StopParsing() # EOF 197 | 198 | return str_end+1 199 | 200 | def handle_longstring(self, match): 201 | end_str = "]" + match.group(1) + "]" # Match number of equals signs 202 | str_end = self.code.find(end_str, match.end()) 203 | if str_end == -1: 204 | raise StopParsing() # EOF 205 | return str_end+len(end_str) 206 | 207 | if __name__ == "__main__": 208 | import sys 209 | logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") 210 | 211 | fname = sys.argv[1] 212 | loc = int(sys.argv[2]) 213 | 214 | with open(fname, "r") as f: 215 | contents = f.read() 216 | 217 | finder = LocalsFinder(contents) 218 | for i in range(1): 219 | scope = finder.run(loc) 220 | if i == 0: 221 | print(scope) 222 | -------------------------------------------------------------------------------- /lua-autocomplete.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "follow_symlinks": true, 6 | "path": "." 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | LuaAutocomplete 3 | =============== 4 | 5 | This is a very basic auto-completion plugin for Sublime Text 3 and the Lua language. 6 | It's meant to be an improvement over Sublime Text's default autocomplete, which is frequently 7 | cluttered with noise from comments, etc. 8 | 9 | Types of autocompletion added: 10 | 11 | * Local variables, including function parameters, for loop indices, etc. 12 | * File paths in `require`. This will search along all folders added to the project for Lua files and directories, 13 | and adds them as autocompletion entries when calling the `require` function. 14 | 15 | Wishlist: 16 | 17 | * Builtins autocompletion. 18 | * Table member completion (really hard!). 19 | --------------------------------------------------------------------------------