html_completions.py (21951B)
1 import sublime, sublime_plugin 2 import re 3 4 5 def match(rex, str): 6 m = rex.match(str) 7 if m: 8 return m.group(0) 9 else: 10 return None 11 12 def make_completion(tag): 13 # make it look like 14 # ("table\tTag", "table>$1</table>"), 15 return (tag + '\tTag', tag + '>$0</' + tag + '>') 16 17 def get_tag_to_attributes(): 18 """ 19 Returns a dictionary with attributes accociated to tags 20 This assumes that all tags can have global attributes as per MDN: 21 https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes 22 """ 23 24 # Map tags to specific attributes applicable for that tag 25 tag_dict = { 26 'a' : ['charset', 'coords', 'download', 'href', 'hreflang', 'media', 'name', 'ping', 'rel', 'rev', 'shape', 'target', 'type'], 27 'abbr' : ['title'], 28 'address' : [], 29 'applet' : ['align', 'alt', 'archive', 'code', 'codebase', 'height', 'hspace', 'name', 'object', 'vspace', 'width'], 30 'area' : ['alt', 'coords', 'download', 'href', 'hreflang', 'media', 'nohref', 'rel', 'shape', 'target'], 31 'article' : [], 32 'aside' : [], 33 'audio' : ['autoplay', 'buffered', 'controls', 'loop', 'muted', 'played', 'preload', 'src', 'volume'], 34 'b' : [], 35 'base' : ['href', 'target'], 36 'basefont' : ['color', 'face', 'size'], 37 'bdi' : [], 38 'bdo' : [], 39 'blockquote' : ['cite'], 40 'body' : ['alink', 'background', 'bgcolor', 'link', 'onafterprint', 'onbeforeprint', 'onbeforeunload', 'onhashchange', 'onmessage', 'onoffline', 'ononline', 'onpopstate', 'onredo', 'onstorage', 'onundo', 'onunload', 'text', 'vlink'], 41 'br' : ['clear'], 42 'button' : ['autofocus', 'disabled', 'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget', 'name', 'type', 'value'], 43 'canvas' : ['height', 'width'], 44 'caption' : ['align'], 45 'cite' : [], 46 'code' : [], 47 'col' : ['align', 'char', 'charoff', 'span', 'valign', 'width'], 48 'colgroup' : ['align', 'char', 'charoff', 'span', 'valign', 'width'], 49 'content' : ['select'], 50 'data' : ['value'], 51 'datalist' : [], 52 'dd' : [], 53 'del' : ['cite', 'datetime'], 54 'details' : ['open'], 55 'dfn' : [], 56 'dir' : ['compact'], 57 'div' : ['align'], 58 'dl' : ['compact'], 59 'dt' : [], 60 'element' : [], 61 'em' : [], 62 'embed' : ['height', 'src', 'type', 'width'], 63 'fieldset' : ['disabled', 'form', 'name'], 64 'figcaption' : [], 65 'figure' : [], 66 'font' : ['color', 'face', 'size'], 67 'footer' : [], 68 'form' : ['accept-charset', 'accept', 'action', 'autocomplete', 'enctype', 'method', 'name', 'novalidate', 'target'], 69 'frame' : ['frameborder', 'longdesc', 'marginheight', 'marginwidth', 'name', 'noresize', 'scrolling', 'src'], 70 'frameset' : ['cols', 'onunload', 'rows'], 71 'h1' : ['align'], 72 'h2' : ['align'], 73 'h3' : ['align'], 74 'h4' : ['align'], 75 'h5' : ['align'], 76 'h6' : ['align'], 77 'head' : ['profile'], 78 'header' : [], 79 'hr' : ['align', 'noshade', 'size', 'width'], 80 'html' : ['manifest', 'version', 'xmlns'], 81 'i' : [], 82 'iframe' : ['align', 'frameborder', 'height', 'longdesc', 'marginheight', 'marginwidth', 'name', 'sandbox', 'scrolling', 'seamless', 'src', 'srcdoc', 'width'], 83 'img' : ['align', 'alt', 'border', 'crossorigin', 'height', 'hspace', 'ismap', 'longdesc', 'name', 'sizes', 'src', 'srcset', 'usemap', 'vspace', 'width'], 84 'input' : ['accept', 'align', 'alt', 'autocomplete', 'autofocus', 'autosave', 'checked', 'disabled', 'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget', 'height', 'inputmode', 'ismap', 'list', 'max', 'maxlength', 'min', 'minlength', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'selectionDirection', 'size', 'spellcheck', 'src', 'step', 'tabindex', 'type', 'usemap', 'value', 'width'], 85 'ins' : ['cite', 'datetime'], 86 'isindex' : ['prompt'], 87 'kbd' : [], 88 'keygen' : ['autofocus', 'challenge', 'disabled', 'form', 'keytype', 'name'], 89 'label' : ['for', 'form'], 90 'legend' : [], 91 'li' : ['type', 'value'], 92 'link' : ['charset', 'crossorigin', 'href', 'hreflang', 'media', 'rel', 'rev', 'sizes', 'target', 'type'], 93 'main' : [], 94 'map' : ['name'], 95 'mark' : [], 96 'menu' : ['compact'], 97 'meta' : ['charset', 'content', 'http-equiv', 'name', 'scheme'], 98 'meter' : ['value', 'min', 'max', 'low', 'high', 'optimum', 'form'], 99 'nav' : [], 100 'noframes' : [], 101 'noscript' : [], 102 'object' : ['align', 'archive', 'border', 'classid', 'codebase', 'codetype', 'data', 'declare', 'form', 'height', 'hspace', 'name', 'standby', 'type', 'typemustmatch', 'usemap', 'vspace', 'width'], 103 'ol' : ['compact', 'reversed', 'start', 'type'], 104 'optgroup' : ['disabled', 'label'], 105 'option' : ['disabled', 'label', 'selected', 'value'], 106 'output' : ['for', 'form', 'name'], 107 'p' : ['align'], 108 'param' : ['name', 'type', 'value', 'valuetype'], 109 'picture' : [], 110 'pre' : ['width'], 111 'progress' : ['max', 'value'], 112 'q' : ['cite'], 113 'rp' : [], 114 'rt' : [], 115 'rtc' : [], 116 's' : [], 117 'samp' : [], 118 'script' : ['async', 'charset', 'defer', 'language', 'src', 'type'], 119 'section' : [], 120 'select' : ['autofocus', 'disabled', 'form', 'multiple', 'name', 'required', 'size'], 121 'shadow' : [], 122 'small' : [], 123 'source' : ['src', 'type'], 124 'span' : [], 125 'strong' : [], 126 'style' : ['disabled', 'media', 'scoped', 'title', 'type'], 127 'sub' : [], 128 'summary': [], 129 'sup' : [], 130 'table' : ['align', 'bgcolor', 'border', 'cellpadding', 'cellspacing', 'frame', 'rules', 'summary', 'width'], 131 'tbody' : ['align', 'char', 'charoff', 'valign'], 132 'td' : ['abbr', 'align', 'axis', 'bgcolor', 'char', 'charoff', 'colspan', 'headers', 'height', 'nowrap', 'rowspan', 'scope', 'valign', 'width'], 133 'template' : ['content'], 134 'textarea' : ['autocomplete', 'autofocus', 'cols', 'disabled', 'form', 'maxlength', 'minlength', 'name', 'placeholder', 'readonly', 'required', 'rows', 'selectionDirection', 'selectionEnd', 'selectionStart', 'spellcheck', 'wrap'], 135 'tfoot' : ['align', 'char', 'charoff', 'valign'], 136 'th' : ['abbr', 'align', 'axis', 'bgcolor', 'char', 'charoff', 'colspan', 'headers', 'height', 'nowrap', 'rowspan', 'scope', 'valign', 'width'], 137 'thead' : ['align', 'char', 'charoff', 'valign'], 138 'time' : ['datetime'], 139 'title' : [], 140 'tr' : ['align', 'bgcolor', 'char', 'charoff', 'valign'], 141 'track' : ['default', 'kind', 'label', 'src', 'srclang'], 142 'u' : [], 143 'ul' : ['compact', 'type'], 144 'var' : [], 145 'video' : ['autoplay', 'autobuffer', 'buffered', 'controls', 'crossorigin', 'height', 'loop', 'muted', 'played', 'preload', 'poster', 'src', 'width'], 146 'wbr' : [] 147 } 148 149 # Assume that global attributes are common to all HTML elements 150 global_attributes = [ 151 'accesskey', 'class', 'contenteditable', 'contextmenu', 'dir', 152 'hidden', 'id', 'lang', 'style', 'tabindex', 'title', 'translate' 153 ] 154 155 # Extend `global_attributes` by the event handler attributes 156 global_attributes.extend([ 157 'onabort', 'onautocomplete', 'onautocompleteerror', 'onauxclick', 'onblur', 158 'oncancel', 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick', 159 'onclose', 'oncontextmenu', 'oncuechange', 'ondblclick', 'ondrag', 160 'ondragend', 'ondragenter', 'ondragexit', 'ondragleave', 'ondragover', 161 'ondragstart', 'ondrop', 'ondurationchange', 'onemptied', 'onended', 162 'onerror', 'onfocus', 'oninput', 'oninvalid', 'onkeydown', 163 'onkeypress', 'onkeyup', 'onload', 'onloadeddata', 'onloadedmetadata', 164 'onloadstart', 'onmousedown', 'onmouseenter', 'onmouseleave', 165 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 166 'onmousewheel', 'onpause', 'onplay', 'onplaying', 'onprogress', 167 'onratechange', 'onreset', 'onresize', 'onscroll', 'onseeked', 168 'onseeking', 'onselect', 'onshow', 'onsort', 'onstalled', 'onsubmit', 169 'onsuspend', 'ontimeupdate', 'ontoggle', 'onvolumechange', 'onwaiting' 170 ]) 171 172 for attributes in tag_dict.values(): 173 attributes.extend(global_attributes) 174 175 # Remove `dir` attribute from `bdi` key, because it is *not* inherited 176 # from the global attributes 177 if 'bdi' in tag_dict: 178 tag_dict['bdi'] = [attr for attr in tag_dict['bdi'] if attr != 'dir'] 179 180 return tag_dict 181 182 183 class HtmlTagCompletions(sublime_plugin.EventListener): 184 """ 185 Provide tag completions for HTML 186 It matches just after typing the first letter of a tag name 187 """ 188 def __init__(self): 189 completion_list = self.default_completion_list() 190 self.prefix_completion_dict = {} 191 # construct a dictionary where the key is first character of 192 # the completion list to the completion 193 for s in completion_list: 194 prefix = s[0][0] 195 self.prefix_completion_dict.setdefault(prefix, []).append(s) 196 197 # construct a dictionary from (tag, attribute[0]) -> [attribute] 198 self.tag_to_attributes = get_tag_to_attributes() 199 200 def on_query_completions(self, view, prefix, locations): 201 # Only trigger within HTML 202 if not view.match_selector(locations[0], "text.html - (source - source text.html)" 203 " - string.quoted - meta.tag.style.end punctuation.definition.tag.begin"): 204 return [] 205 206 # check if we are inside a tag 207 is_inside_tag = view.match_selector(locations[0], 208 "text.html meta.tag - text.html punctuation.definition.tag.begin") 209 210 return self.get_completions(view, prefix, locations, is_inside_tag) 211 212 def get_completions(self, view, prefix, locations, is_inside_tag): 213 # see if it is in tag.attr or tag#attr format 214 if not is_inside_tag: 215 tag_attr_expr = self.expand_tag_attributes(view, locations) 216 if tag_attr_expr != []: 217 return (tag_attr_expr, sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS) 218 219 pt = locations[0] - len(prefix) - 1 220 ch = view.substr(sublime.Region(pt, pt + 1)) 221 222 # print('prefix:', prefix) 223 # print('location0:', locations[0]) 224 # print('substr:', view.substr(sublime.Region(locations[0], locations[0] + 3)), '!end!') 225 # print('is_inside_tag', is_inside_tag) 226 # print('ch:', ch) 227 228 completion_list = [] 229 if is_inside_tag and ch != '<': 230 if ch in [' ', '\t', '\n']: 231 # maybe trying to type an attribute 232 completion_list = self.get_attribute_completions(view, locations[0], prefix) 233 # only ever trigger completion inside a tag if the previous character is a < 234 # this is needed to stop completion from happening when typing attributes 235 return (completion_list, sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS) 236 237 if prefix == '': 238 # need completion list to match 239 return ([], sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS) 240 241 # match completion list using prefix 242 completion_list = self.prefix_completion_dict.get(prefix[0], []) 243 244 # if the opening < is not here insert that 245 if ch != '<': 246 completion_list = [(pair[0], '<' + pair[1]) for pair in completion_list] 247 248 flags = 0 249 if is_inside_tag: 250 flags = sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS 251 252 return (completion_list, flags) 253 254 def default_completion_list(self): 255 """ 256 Generate a default completion list for HTML 257 """ 258 default_list = [] 259 normal_tags = ([ 260 'abbr', 'acronym', 'address', 'applet', 'article', 'aside', 261 'audio', 'b', 'basefont', 'bdi', 'bdo', 'big', 'blockquote', 262 'body', 'button', 'center', 'canvas', 'caption', 'cdata', 263 'cite', 'colgroup', 'code', 'content', 'data', 'datalist', 264 'dir', 'div', 'dd', 'del', 'details', 'dfn', 'dl', 'dt', 'element', 265 'em', 'embed', 'fieldset', 'figure', 'figcaption', 'font', 'footer', 266 'form', 'frame', 'frameset', 'head', 'header', 'h1', 'h2', 'h3', 267 'h4', 'h5', 'h6', 'i', 'ins', 'isindex', 'kbd', 'keygen', 268 'li', 'label', 'legend', 'main', 'map', 'mark', 'meter', 269 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 270 'option', 'output', 'p', 'picture', 'pre', 'q', 'rp', 271 'rt', 'rtc', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 272 'small', 'span', 'strong', 'sub', 'summary', 'sup', 273 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 274 'thead', 'time', 'title', 'tr', 'tt', 'u', 'ul', 'var', 275 'video' 276 ]) 277 278 for tag in normal_tags: 279 default_list.append(make_completion(tag)) 280 default_list.append(make_completion(tag.upper())) 281 282 default_list += ([ 283 ('a\tTag', 'a href=\"$1\">$0</a>'), 284 ('area\tTag', 'area shape=\"$1\" coords=\"$2\" href=\"$3\">'), 285 ('audio\tTag', 'audio src=\"$1\">$0</audio>'), 286 ('base\tTag', 'base href=\"$1\">'), 287 ('br\tTag', 'br>'), 288 ('col\tTag', 'col>'), 289 ('hr\tTag', 'hr>'), 290 ('iframe\tTag', 'iframe src=\"$1\">$0</iframe>'), 291 ('input\tTag', 'input type=\"$1\" name=\"$2\">'), 292 ('img\tTag', 'img src=\"$1\">'), 293 ('link\tTag', 'link rel=\"stylesheet\" type=\"text/css\" href=\"$1\">'), 294 ('meta\tTag', 'meta ${1:charset=\"utf-8\"}>'), 295 ('param\tTag', 'param name=\"$1\" value=\"$2\">'), 296 ('progress\tTag', 'progress value=\"$1\" max=\"$2\">'), 297 ('script\tTag', 'script${2: type=\"${1:text/javascript}\"}>$0</script>'), 298 ('source\tTag', 'source src=\"$1\" type=\"$2\">'), 299 ('style\tTag', 'style type=\"${1:text/css}\">$0</style>'), 300 ('track\tTag', 'track kind=\"$1\" src=\"$2\">'), 301 ('wbr\tTag', 'wbr>'), 302 ('video\tTag', 'video src=\"$1\">$0</video>') 303 ]) 304 305 return default_list 306 307 # This responds to on_query_completions, but conceptually it's expanding 308 # expressions, rather than completing words. 309 # 310 # It expands these simple expressions: 311 # tag.class 312 # tag#id 313 def expand_tag_attributes(self, view, locations): 314 # Get the contents of each line, from the beginning of the line to 315 # each point 316 lines = [view.substr(sublime.Region(view.line(l).a, l)) 317 for l in locations] 318 319 # Reverse the contents of each line, to simulate having the regex 320 # match backwards 321 lines = [l[::-1] for l in lines] 322 323 # Check the first location looks like an expression 324 rex = re.compile("([\w-]+)([.#])(\w+)") 325 expr = match(rex, lines[0]) 326 if not expr: 327 return [] 328 329 # Ensure that all other lines have identical expressions 330 for i in range(1, len(lines)): 331 ex = match(rex, lines[i]) 332 if ex != expr: 333 return [] 334 335 # Return the completions 336 arg, op, tag = rex.match(expr).groups() 337 338 arg = arg[::-1] 339 tag = tag[::-1] 340 expr = expr[::-1] 341 342 if op == '.': 343 snippet = '<{0} class=\"{1}\">$1</{0}>$0'.format(tag, arg) 344 else: 345 snippet = '<{0} id=\"{1}\">$1</{0}>$0'.format(tag, arg) 346 347 return [(expr, snippet)] 348 349 def get_attribute_completions(self, view, pt, prefix): 350 SEARCH_LIMIT = 500 351 search_start = max(0, pt - SEARCH_LIMIT - len(prefix)) 352 line = view.substr(sublime.Region(search_start, pt + SEARCH_LIMIT)) 353 354 line_head = line[0:pt - search_start] 355 line_tail = line[pt - search_start:] 356 357 # find the tag from end of line_head 358 i = len(line_head) - 1 359 tag = None 360 space_index = len(line_head) 361 while i >= 0: 362 c = line_head[i] 363 if c == '<': 364 # found the open tag 365 tag = line_head[i + 1:space_index] 366 break 367 elif c == ' ': 368 space_index = i 369 i -= 1 370 371 # check that this tag looks valid 372 if not tag or not tag.isalnum(): 373 return [] 374 375 # determines whether we need to close the tag 376 # default to closing the tag 377 suffix = '>' 378 379 for c in line_tail: 380 if c == '>': 381 # found end tag 382 suffix = '' 383 break 384 elif c == '<': 385 # found another open tag, need to close this one 386 break 387 388 if suffix == '' and not line_tail.startswith(' ') and not line_tail.startswith('>'): 389 # add a space if not there 390 suffix = ' ' 391 392 # got the tag, now find all attributes that match 393 attributes = self.tag_to_attributes.get(tag, []) 394 # ("class\tAttr", "class="$1">"), 395 attri_completions = [(a + '\tAttr', a + '="$1"' + suffix) for a in attributes] 396 return attri_completions 397 398 399 # unit testing 400 # to run it in sublime text: 401 # import HTML.html_completions 402 # HTML.html_completions.Unittest.run() 403 404 import unittest 405 406 class Unittest(unittest.TestCase): 407 408 class Sublime: 409 INHIBIT_WORD_COMPLETIONS = 1 410 INHIBIT_EXPLICIT_COMPLETIONS = 2 411 412 # this view contains a hard coded one line super simple HTML fragment 413 class View: 414 def __init__(self): 415 self.buf = '<tr><td class="a">td.class</td></tr>' 416 417 def line(self, pt): 418 # only ever 1 line 419 return sublime.Region(0, len(self.buf)) 420 421 def substr(self, region): 422 return self.buf[region.a : region.b] 423 424 def run(): 425 # redefine the modules to use the mock version 426 global sublime 427 428 sublime_module = sublime 429 # use the normal region 430 Unittest.Sublime.Region = sublime.Region 431 sublime = Unittest.Sublime 432 433 test = Unittest() 434 test.test_simple_completion() 435 test.test_inside_tag_completion() 436 test.test_inside_tag_no_completion() 437 test.test_expand_tag_attributes() 438 439 # set it back after testing 440 sublime = sublime_module 441 442 # def get_completions(self, view, prefix, locations, is_inside_tag): 443 def test_simple_completion(self): 444 # <tr><td class="a">td.class</td></tr> 445 view = Unittest.View() 446 completor = HtmlTagCompletions() 447 448 # simulate typing 'tab' at the start of the line, it is outside a tag 449 completion_list, flags = completor.get_completions(view, 'tab', [0], False) 450 451 # should give a bunch of tags that starts with t 452 self.assertEqual(completion_list[0], ('table\tTag', '<table>$0</table>')) 453 self.assertEqual(completion_list[1], ('tbody\tTag', '<tbody>$0</tbody>')) 454 # don't suppress word based completion 455 self.assertEqual(flags, 0) 456 457 def test_inside_tag_completion(self): 458 # <tr><td class="a">td.class</td></tr> 459 view = Unittest.View() 460 completor = HtmlTagCompletions() 461 462 # simulate typing 'h' after <tr><, i.e. <tr><h 463 completion_list, flags = completor.get_completions(view, 'h', [6], True) 464 465 # should give a bunch of tags that starts with h, and without < 466 self.assertEqual(completion_list[0], ('head\tTag', 'head>$0</head>')) 467 self.assertEqual(completion_list[1], ('header\tTag', 'header>$0</header>')) 468 self.assertEqual(completion_list[2], ('h1\tTag', 'h1>$0</h1>')) 469 # suppress word based completion 470 self.assertEqual(flags, sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS) 471 472 # simulate typing 'he' after <tr><, i.e. <tr><he 473 completion_list, flags = completor.get_completions(view, 'he', [7], True) 474 475 # should give a bunch of tags that starts with h, and without < (it filters only on the first letter of the prefix) 476 self.assertEqual(completion_list[0], ('head\tTag', 'head>$0</head>')) 477 self.assertEqual(completion_list[1], ('header\tTag', 'header>$0</header>')) 478 self.assertEqual(completion_list[2], ('h1\tTag', 'h1>$0</h1>')) 479 # suppress word based completion 480 self.assertEqual(flags, sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS) 481 482 def test_inside_tag_no_completion(self): 483 # <tr><td class="a">td.class</td></tr> 484 view = Unittest.View() 485 completor = HtmlTagCompletions() 486 487 # simulate typing 'h' after <tr><td , i.e. <tr><td h 488 completion_list, flags = completor.get_completions(view, 'h', [8], True) 489 490 # should give nothing, but disable word based completions, since it is inside a tag 491 self.assertEqual(completion_list, []) 492 self.assertEqual(flags, sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS) 493 494 def test_expand_tag_attributes(self): 495 # <tr><td class="a">td.class</td></tr> 496 view = Unittest.View() 497 completor = HtmlTagCompletions() 498 499 # simulate typing tab after td.class 500 completion_list, flags = completor.get_completions(view, '', [26], False) 501 502 # should give just one completion, and suppress word based completion 503 self.assertEqual(completion_list, [('td.class', '<td class="class">$1</td>$0')]) 504 self.assertEqual(flags, sublime.INHIBIT_WORD_COMPLETIONS | sublime.INHIBIT_EXPLICIT_COMPLETIONS)