From d91994ca2109c2453fca06e715c1af279cd77a4e Mon Sep 17 00:00:00 2001 From: Keith Hall Date: Sat, 30 Nov 2019 10:04:12 +0200 Subject: [PATCH 1/7] fix autocompletion in ST4 --- xpath.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/xpath.py b/xpath.py index 3b240d5..3381dc1 100644 --- a/xpath.py +++ b/xpath.py @@ -927,8 +927,11 @@ def command_complete(self, cancelled): def show_input_panel(self, initial_value): super().show_input_panel(initial_value) + settings = self.input_panel.settings() + settings.set('auto_complete', True) + settings.set('auto_complete_include_snippets_when_typing', False) if len(self.arguments['auto_completion_triggers'] or '') > 0: - self.input_panel.settings().set('auto_complete_triggers', [ {'selector': 'query.xml.xpath - string', 'characters': self.arguments['auto_completion_triggers']} ]) + settings.set('auto_complete_triggers', [ {'selector': 'query.xml.xpath - string', 'characters': self.arguments['auto_completion_triggers']} ]) def on_query_completions(self, prefix, locations): # moved from .sublime-completions file here - https://github.com/SublimeTextIssues/Core/issues/819 flags = sublime.INHIBIT_WORD_COMPLETIONS From 51556d8054191cd58ebab92be5b6089e1d90e51f Mon Sep 17 00:00:00 2001 From: Keith Hall Date: Mon, 16 Aug 2021 22:31:02 +0300 Subject: [PATCH 2/7] rework tests --- example_xml_ns.xml | 56 ++++++------- lxml_parser.py | 5 +- sublime_lxml.py | 4 +- tests.py | 198 +++++++++++++++++++++++---------------------- xpath.py | 2 + xpath_tests.txt | 129 +++++++++++++++++++++++++++++ 6 files changed, 267 insertions(+), 127 deletions(-) create mode 100644 xpath_tests.txt diff --git a/example_xml_ns.xml b/example_xml_ns.xml index ee0b05f..b28dce3 100644 --- a/example_xml_ns.xml +++ b/example_xml_ns.xml @@ -1,31 +1,31 @@ - - - - - - - - - - - - - - - - - - - - - - - sample text lorem ipsum etc.abcghi + + + + + + + + + + + + + + + + + + + + + + + sample text lorem ipsum etc.abcghi diff --git a/lxml_parser.py b/lxml_parser.py index c1d52b9..b4c9b56 100644 --- a/lxml_parser.py +++ b/lxml_parser.py @@ -305,7 +305,10 @@ def get_results_for_xpath_query(query, tree, context = None, namespaces = None, if namespaces[prefix][0] != '': nsmap[prefix] = namespaces[prefix][0] - xpath = etree.XPath(query, namespaces = nsmap) + try: + xpath = etree.XPath(query, namespaces = nsmap) + except Exception as e: + raise ValueError(query) from e results = execute_xpath_query(tree, xpath, context, **variables) return results diff --git a/sublime_lxml.py b/sublime_lxml.py index 1996a95..beeeb93 100644 --- a/sublime_lxml.py +++ b/sublime_lxml.py @@ -3,8 +3,8 @@ from .sublime_helper import get_scopes import re -RE_TAG_NAME_END_POS = re.compile('[>\s/]') -RE_TAG_ATTRIBUTES = re.compile('\s+((\w+(?::\w+)?)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'))') +RE_TAG_NAME_END_POS = re.compile(r'[>\s/]') +RE_TAG_ATTRIBUTES = re.compile(r'\s+((\w+(?::\w+)?)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'))') # TODO: consider subclassing etree.ElementBase and adding as methods to that def getNodeTagRegion(view, node, position_type): diff --git a/tests.py b/tests.py index 4b9b86e..a8a58a8 100644 --- a/tests.py +++ b/tests.py @@ -6,24 +6,26 @@ from .lxml_parser import * from .sublime_lxml import parse_xpath_query_for_completions -class RunXpathTestsCommand(sublime_plugin.TextCommand): # sublime.active_window().active_view().run_command('run_xpath_tests') - def run(self, edit): +class RunXpathTestsCommand(sublime_plugin.WindowCommand): # sublime.active_window().run_command('run_xpath_tests') + def run(self): try: xml = sublime.load_resource(sublime.find_resources('example_xml_ns.xml')[0]) tree, all_elements = lxml_etree_parse_xml_string_with_location(xml) - + def sublime_lxml_completion_tests(): def test_xpath_completion(xpath, expectation): - view = self.view.window().create_output_panel('xpath_test') - + view = self.window.create_output_panel('xpath_test') + view.assign_syntax('xpath.sublime-syntax') - - view.erase(edit, sublime.Region(0, view.size())) - view.insert(edit, 0, xpath) + + view.run_command('select_all') + view.run_command('insert', { 'characters': xpath }) + #view.erase(edit, sublime.Region(0, view.size())) + #view.insert(edit, 0, xpath) result = parse_xpath_query_for_completions(view, view.size()) - + assert result == expectation, 'xpath: ' + repr(xpath) + '\nexpected: ' + repr(expectation) + '\nactual: ' + repr(result) - + test_xpath_completion('', ['']) test_xpath_completion('/', ['/']) test_xpath_completion('/root/', ['/root/']) @@ -60,16 +62,19 @@ def test_xpath_completion(xpath, expectation): test_xpath_completion('//*[starts-with (name (), "foobar")]/', ['//*[starts-with (name (), "foobar")]/']) test_xpath_completion('//*[number(text())*2=246]/', ['//*[number(text())*2=246]/']) test_xpath_completion('//*[number(text())*', ['//*', '']) - + def sublime_lxml_goto_node_tests(): - self.view.window().run_command('new_file') - view = self.view.window().active_view() - - view.insert(edit, 0, xml) - view.set_syntax_file('xml.sublime-syntax') + self.window.run_command('new_file') + view = self.window.active_view() + + view.run_command('select_all') + view.settings().set('auto_indent', False) + view.settings().set('xpath_test_file', True) + view.run_command('insert', { 'characters': xml }) + view.set_syntax_file('XML.sublime-syntax') view.set_read_only(True) view.set_scratch(True) # so we don't get a message asking to save when we close the view - + # check that going to a text node works # check that going to an element node works # - names @@ -86,101 +91,102 @@ def sublime_lxml_goto_node_tests(): # - none def assert_expected_cursors(expected_cursors, details): for index, actual_cursor in enumerate(view.sel()): - assert len(expected_cursors) > index, details + 'unexpected cursor: ' + repr(actual_cursor) - assert expected_cursors[index] == (actual_cursor.begin(), actual_cursor.end()), details + 'expected: ' + repr(expected_cursors[index]) + '\nactual: ' + repr(actual_cursor) - assert len(expected_cursors) == len(view.sel()), details + 'expected cursors missing: ' + repr(expected_cursors[len(view.sel()):]) - - def goto_xpath(xpath, element_type, attribute_type, expected_cursors): + actual_cursor_begin = view.rowcol(actual_cursor.begin()) + actual_cursor_end = view.rowcol(actual_cursor.end()) + + assert len(expected_cursors) > index, details + '\nunexpected cursor: ' + repr((actual_cursor_begin, actual_cursor_end)) + assert expected_cursors[index] == (actual_cursor_begin, actual_cursor_end), details + '\nexpected: ' + repr(expected_cursors[index]) + '\nactual: ' + repr((actual_cursor_begin, actual_cursor_end)) + assert len(expected_cursors) == len(view.sel()), details + '\nexpected cursors missing: ' + repr(expected_cursors[len(view.sel()):]) + + def goto_xpath(xpath, element_type, attribute_type, expected_cursors, test_line_number=-1): view.run_command('select_results_from_xpath_query', { 'xpath': xpath, 'goto_element': element_type, 'goto_attribute': attribute_type }) - assert_expected_cursors(expected_cursors, 'xpath: "' + xpath + '"\nelement_type: ' + repr(element_type) + '\nattribute_type: ' + repr(attribute_type) + '\n') - + #assert_expected_cursors(expected_cursors, f'xpath: "{xpath}"\nelement_type: {repr(element_type)}\nattribute_type: {repr(attribute_type)}\ntest line number: {test_line_number}') + assert_expected_cursors(expected_cursors, 'xpath: "{xpath}"\nelement_type: {element_type}\nattribute_type: {attribute_type}\ntest line number: {test_line_number}'.format( + **{ + 'xpath': xpath, + 'element_type': element_type, + 'attribute_type': attribute_type, + 'test_line_number': test_line_number + }) # TODO: use locals()? + ) + def xpath_tests(): - goto_xpath('/test/default1:hello', 'open', None, [(33, 38)]) - goto_xpath('/test/default1:hello', 'names', None, [(33, 38), (1189, 1194)]) - goto_xpath('/test/default1:hello', 'entire', None, [(32, 1195)]) - goto_xpath('/test/default1:hello/default2:world', 'close', None, [(1178, 1183)]) - goto_xpath('/test/default1:hello/default2:world', 'content', None, [(1010, 1176)]) - goto_xpath('/test/default1:hello/default2:world', 'open_attributes', None, [(992, 1009)]) - goto_xpath('/test/default1:hello/default2:world/default2:example', 'content', None, [(1096, 1096)]) - goto_xpath('/test/default1:hello/default2:world/default2:example', 'open_attributes', None, [(1093, 1094)]) - goto_xpath('/test/default1:hello/default2:world/default2:example', 'names', None, [(1086, 1093)]) - goto_xpath('/test/default1:hello/default2:world/default2:example', 'close', None, [(1086, 1093)]) - goto_xpath('//hij', 'open_attributes', None, [(2805, 2805)]) - - goto_xpath('(//text())[1]', None, None, [(29, 32)]) - goto_xpath("//text()[contains(., 'text')]", None, None, [(2643, 2654)]) - goto_xpath("/test/text/following-sibling::text() | /test/text/following-sibling::*/text()", None, None, [(2780, 2801), (2806, 2821), (2827, 2844)]) # text nodes including CDATA - goto_xpath('(//*)[position() < 3]', 'open', None, [(24, 28), (33, 38)]) # multiple elements - goto_xpath('(//*)[position() < 3]', 'names', None, [(24, 28), (33, 38), (1189, 1194), (2846, 2850)]) # multiple elements - goto_xpath('/test/default3:more[2]/an2:yet_another', 'open', None, [(1950, 1964)]) - # relative nodes from context node - goto_xpath('../preceding-sibling::default3:more/descendant-or-self::*', 'open', None, [(1199, 1203), (1480, 1490)]) - # multiple contexts - goto_xpath('$contexts/..', 'open', None, [(24, 28), (1199, 1203)]) - - # attributes - goto_xpath('/test/text/@attr1', None, 'value', [(2622, 2627)]) - goto_xpath('/test/text/@*', None, 'name', [(2615, 2620), (2629, 2634)]) - goto_xpath('/test/text/@*', None, 'entire', [(2615, 2628), (2629, 2642)]) - goto_xpath('//@abc:another_value', None, 'entire', [(2728, 2753)]) # attribute with namespace prefix - - random_pos = random.randint(0, view.size()) - view.sel().clear() - view.sel().add(sublime.Region(random_pos)) - goto_xpath('substring-before(//text()[contains(., ''text'')][1], ''text'')', None, None, [(random_pos, random_pos)]) # check that selection didn't move - goto_xpath('//*', 'none', None, [(random_pos, random_pos)]) # check that selection didn't move - goto_xpath('/test/text/@attr1', None, 'none', [(random_pos, random_pos)]) # check that selection didn't move - + test_lines = sublime.load_resource(sublime.find_resources('xpath_tests.txt')[0]).split('\n') + index = 0 + while index < len(test_lines): + xpath = test_lines[index] + if xpath.strip() == '' or xpath.startswith('#'): + index += 1 + continue + + element_type, _, attribute_type = test_lines[index + 1].partition(' ') + expected_cursor_count = int(test_lines[index + 2]) + expected_cursors = list() #set() + + if expected_cursor_count: + for index in range(index + 3, index + 3 + expected_cursor_count): + expected_cursors.append(eval(test_lines[index])) # TODO: don't rely on eval to parse the tuple + else: + index += 2 + random_pos = random.randint(0, view.size()) + view.sel().clear() + view.sel().add(sublime.Region(random_pos)) + rowcol = view.rowcol(random_pos) + expected_cursors.append((rowcol, rowcol)) + + goto_xpath(xpath, element_type, attribute_type, expected_cursors, index) + index += 1 + def goto_relative(direction, element_type, expected_cursors): view.run_command('goto_relative', { 'direction': direction, 'goto_element': element_type }) assert_expected_cursors(expected_cursors, 'direction: "' + direction + '"\n') - + def relative_tests(): - goto_xpath('/test', 'open', None, [(24, 28)]) - goto_relative('self', 'open', [(24, 28)]) - goto_relative('self', 'close', [(2846, 2850)]) - goto_relative('self', 'content', [(29, 2844)]) - goto_relative('self', 'entire', [(23, 2851)]) - - goto_xpath('/test/default1:hello/default2:world', 'open', None, [(987, 992)]) - goto_relative('self', 'close', [(1178, 1183)]) - goto_relative('self', 'names', [(987, 992), (1178, 1183)]) - goto_relative('self', 'close', [(1178, 1183)]) - goto_relative('parent', 'open', [(33, 38)]) - - goto_xpath('/test/default3:more[1]', 'open', None, [(1199, 1203)]) - goto_relative('prev', 'open', [(33, 38)]) - goto_relative('next', 'open', [(1199, 1203)]) - goto_relative('next', 'open', [(1576, 1580)]) - goto_relative('prev', 'names', [(1199, 1203), (1567, 1571)]) - goto_relative('next', 'content', [(1645, 2145)]) - goto_xpath('/test/default3:more', 'open', None, [(1199, 1203), (1576, 1580)]) - goto_relative('prev', 'open', [(33, 38), (1199, 1203)]) - goto_relative('next', 'content', [(1242, 1565), (1645, 2145)]) - goto_xpath('/test/default3:more', 'open', None, [(1199, 1203), (1576, 1580)]) - goto_relative('self', 'close', [(1567, 1571), (2147, 2151)]) - goto_relative('parent', 'open', [(24, 28)]) - goto_xpath('/test/default3:more[1] | /test/default3:more[2]/an2:yet_another', 'open', None, [(1199, 1203), (1950, 1964)]) - goto_relative('parent', 'open', [(24, 28), (1576, 1580)]) - - goto_relative('prev', 'open', [(24, 28), (1576, 1580)]) # prev should fail, so assert the position is the same as previously - + goto_xpath('/test', 'open', None, [((1, 1), (1, 5))]) + goto_relative('self', 'open', [((1, 1), (1, 5))]) + goto_relative('self', 'close', [((30, 2), (30, 6))]) + goto_relative('self', 'content', [((1, 6), (30, 0))]) + goto_relative('self', 'entire', [((1, 0), (30, 7))]) + + goto_xpath('/test/default1:hello/default2:world', 'open', None, [((9, 9), (9, 14))]) + goto_relative('self', 'close', [((11, 10), (11, 15))]) + goto_relative('self', 'names', [((9, 9), (9, 14)), ((11, 10), (11, 15))]) + goto_relative('self', 'close', [((11, 10), (11, 15))]) + goto_relative('parent', 'open', [((2, 5), (2, 10))]) + + goto_xpath('/test/default3:more[1]', 'open', None, [((13, 5), (13, 9))]) + goto_relative('prev', 'open', [((2, 5), (2, 10))]) + goto_relative('next', 'open', [((13, 5), (13, 9))]) + goto_relative('next', 'open', [((16, 5), (16, 9))]) + goto_relative('prev', 'names', [((13, 5), (13, 9)), ((15, 6), (15, 10))]) + goto_relative('next', 'content', [((16, 74), (19, 4))]) + goto_xpath('/test/default3:more', 'open', None, [((13, 5), (13, 9)), ((16, 5), (16, 9))]) + goto_relative('prev', 'open', [((2, 5), (2, 10)), ((13, 5), (13, 9))]) + goto_relative('next', 'content', [((13, 48), (15, 4)), ((16, 74), (19, 4))]) + goto_xpath('/test/default3:more', 'open', None, [((13, 5), (13, 9)), ((16, 5), (16, 9))]) + goto_relative('self', 'close', [((15, 6), (15, 10)), ((19, 6), (19, 10))]) + goto_relative('parent', 'open', [((1, 1), (1, 5))]) + goto_xpath('/test/default3:more[1] | /test/default3:more[2]/an2:yet_another', 'open', None, [((13, 5), (13, 9)), ((17, 9), (17, 23))]) + goto_relative('parent', 'open', [((1, 1), (1, 5)), ((16, 5), (16, 9))]) + + goto_relative('prev', 'open', [((1, 1), (1, 5)), ((16, 5), (16, 9))]) # prev should fail, so assert the position is the same as previously + xpath_tests() relative_tests() - + # close the view we opened for testing view.window().run_command('close') - - + + sublime_lxml_completion_tests() sublime_lxml_goto_node_tests() - + # TODO: check the results of an xpath query # e.g. `count(//@*)` - + print('all XPath tests passed') except Exception as e: print('XPath tests failed') print(repr(e)) traceback.print_tb(e.__traceback__) - + diff --git a/xpath.py b/xpath.py index 3381dc1..1292b57 100644 --- a/xpath.py +++ b/xpath.py @@ -428,6 +428,8 @@ def on_pre_close(self, view): previous_first_selection.pop(view.id(), None) if view.file_name() is None: # if the file has no filename associated with it + if view.settings().get('xpath_test_file', None): + return #if not getBoolValueFromArgsOrSettings('global_query_history', None, True): # if global history isn't enabled # remove_key_from_xpath_query_history(get_history_key_for_view(view)) #else: diff --git a/xpath_tests.txt b/xpath_tests.txt new file mode 100644 index 0000000..85de8c5 --- /dev/null +++ b/xpath_tests.txt @@ -0,0 +1,129 @@ +/test/default1:hello +open +1 +(2, 5), (2, 10) +/test/default1:hello +names +2 +(2, 5), (2, 10) +(12, 6), (12, 11) +/test/default1:hello +entire +1 +(2, 4), (12, 12) +/test/default1:hello/default2:world +close +1 +(11, 10), (11, 15) +/test/default1:hello/default2:world +content +1 +(9, 32), (11, 8) +/test/default1:hello/default2:world +open_attributes +1 +(9, 14), (9, 31) +/test/default1:hello/default2:world/default2:example +content +1 +(10, 23), (10, 23) +/test/default1:hello/default2:world/default2:example +open_attributes +1 +(10, 20), (10, 21) +/test/default1:hello/default2:world/default2:example +names +1 +(10, 13), (10, 20) +/test/default1:hello/default2:world/default2:example +close +1 +(10, 13), (10, 20) +//hij +open_attributes +1 +(29, 134), (29, 134) +(//text())[1] +open +1 +(1, 6), (2, 4) +//text()[contains(., 'text')] +open +1 +(27, 38), (27, 49) + +# text nodes including CDATA +/test/text/following-sibling::text() | /test/text/following-sibling::*/text() +N/A +3 +(29, 109), (29, 130) +(29, 135), (29, 150) +(29, 156), (30, 0) + +# multiple elements +(//*)[position() < 3] +open +2 +(1, 1), (1, 5) +(2, 5), (2, 10) + +(//*)[position() < 3] +names +4 +(1, 1), (1, 5) +(2, 5), (2, 10) +(12, 6), (12, 11) +(30, 2), (30, 6) + +/test/default3:more[2]/an2:yet_another +open +1 +(17, 9), (17, 23) + +# relative nodes from context node +../preceding-sibling::default3:more/descendant-or-self::* +open +2 +(13, 5), (13, 9) +(14, 9), (14, 19) + +# multiple contexts +$contexts/.. +open +2 +(1, 1), (1, 5) +(13, 5), (13, 9) + +# attributes +/test/text/@attr1 +None value +1 +(27, 17), (27, 22) +/test/text/@* +None name +2 +(27, 10), (27, 15) +(27, 24), (27, 29) + +/test/text/@* +None entire +2 +(27, 10), (27, 23) +(27, 24), (27, 37) + +# attribute with namespace prefix +//@abc:another_value +None entire +1 +(29, 57), (29, 82) + +# check that selection didn't move for no matches +substring-before(//text()[contains(., 'text')][1], 'text') +open +0 +//* +none +0 +/test/text/@attr1 +none none +0 From 76dd18e83d189bece4600b3fd070155ab4de1ba3 Mon Sep 17 00:00:00 2001 From: Keith Hall Date: Thu, 19 Aug 2021 22:26:13 +0300 Subject: [PATCH 3/7] use ST4 completion items for completions --- xpath.py | 357 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 200 insertions(+), 157 deletions(-) diff --git a/xpath.py b/xpath.py index 1292b57..d9e9be1 100644 --- a/xpath.py +++ b/xpath.py @@ -88,7 +88,7 @@ def buildTreeForViewRegion(view, region_scope): log_entry = e.error_log[0] text = 'line ' + str(log_entry.line + offset[0]) + ', column ' + str(log_entry.column + offset[1]) + ' - ' + log_entry.message view.set_status('xpath_error', parse_error + text) - + return (tree, all_elements) def ensureTreeCacheIsCurrent(view): @@ -96,14 +96,14 @@ def ensureTreeCacheIsCurrent(view): global change_counters new_count = view.change_count() old_count = change_counters.get(view.id(), None) - + global xml_roots global xml_elements if old_count is None or new_count > old_count: change_counters[view.id()] = new_count view.set_status('xpath', 'XML being parsed...') view.erase_status('xpath_error') - + xml_roots[view.id()] = [] xml_elements[view.id()] = [] for tree, all_elements in buildTreesForView(view): @@ -112,7 +112,7 @@ def ensureTreeCacheIsCurrent(view): root = tree.getroot() xml_roots[view.id()].append(root) xml_elements[view.id()].append(all_elements) - + view.erase_status('xpath') global previous_first_selection previous_first_selection[view.id()] = None @@ -121,20 +121,20 @@ def ensureTreeCacheIsCurrent(view): class GotoXmlParseErrorCommand(sublime_plugin.TextCommand): def run(self, edit, **args): view = self.view - + global parse_error detail = view.get_status('xpath_error')[len(parse_error + 'line '):].split(' - ')[0].split(', column ') point = view.text_point(int(detail[0]) - 1, int(detail[1]) - 1) - + view.sel().clear() view.sel().add(point) - + view.show_at_center(point) - + def is_enabled(self, **args): global parse_error return containsSGML(self.view) and self.view.get_status('xpath_error').startswith(parse_error) - + def is_visible(self, **args): return containsSGML(self.view) @@ -145,43 +145,43 @@ def getXPathOfNodes(nodes, args): show_namespace_prefixes_from_query = getBoolValueFromArgsOrSettings('show_namespace_prefixes_from_query', args, False) case_sensitive = getBoolValueFromArgsOrSettings('case_sensitive', args, True) all_attributes = getBoolValueFromArgsOrSettings('show_all_attributes', args, False) - + global settings wanted_attributes = settings.get('attributes_to_include', []) if not case_sensitive: wanted_attributes = [attrib.lower() for attrib in wanted_attributes] - + def getTagNameWithMappedPrefix(node, namespaces): tag = getTagName(node) if show_namespace_prefixes_from_query and tag[0] is not None: # if the element belongs to a namespace unique_prefix = next((prefix for prefix in namespaces.keys() if namespaces[prefix] == (tag[0], node.prefix)), None) # find the first prefix in the map that relates to this uri if unique_prefix is not None: tag = (tag[0], tag[1], unique_prefix + ':' + tag[1]) # ensure that the path we display can be used to query the element - + if not case_sensitive: tag = (tag[0], tag[1].lower(), tag[2].lower()) - + return tag - + def getNodePathPart(node, namespaces): tag = getTagNameWithMappedPrefix(node, namespaces) - + output = tag[2] - + if include_indexes: siblings = node.itersiblings(preceding = True) index = 1 - + def compare(sibling): if not isinstance(sibling, LocationAwareElement): # skip comments return False sibling_tag = getTagNameWithMappedPrefix(sibling, namespaces) return sibling_tag == tag # namespace uri, prefix and tag name must all match - + for sibling in siblings: if compare(sibling): index += 1 - + # if there are no previous sibling matches, check next siblings to see if we should index this node multiple = index > 1 if not multiple: @@ -190,10 +190,10 @@ def compare(sibling): if compare(sibling): multiple = True break - + if multiple: output += '[' + str(index) + ']' - + if include_attributes: attributes_to_show = [] for attr_name in node.attrib: @@ -207,15 +207,15 @@ def compare(sibling): include_attribute = attr_name in wanted_attributes if not include_attribue and len(attr) == 2: include_attribue = attr[0] + ':*' in wanted_attributes or '*:' + attr[1] in wanted_attributes - + if include_attribute: attributes_to_show.append('@' + attr_name + ' = "' + node.get(attr_name) + '"') - + if len(attributes_to_show) > 0: output += '[' + ' and '.join(attributes_to_show) + ']' - + return output - + def getNodePathSegments(node, namespaces, root): if isinstance(node, etree.CommentBase): node = node.getparent() @@ -224,28 +224,28 @@ def getNodePathSegments(node, namespaces, root): node = node.getparent() yield getNodePathPart(node, namespaces) yield '' - + def getNodePath(node, namespaces, root): return '/'.join(reversed(list(getNodePathSegments(node, namespaces, root)))) - + roots = {} for node in nodes: tree = node.getroottree() root = tree.getroot() roots.setdefault(root, []).append(node) - + paths = [] for root in roots.keys(): for node in roots[root]: namespaces = None if show_namespace_prefixes_from_query: namespaces = namespace_map_for_tree(root.getroottree()) - + paths.append(getNodePath(node, namespaces, root)) - + if unique: paths = list(getUniqueItems(paths)) - + return paths def getExactXPathOfNodes(nodes): @@ -264,7 +264,7 @@ def updateStatusToCurrentXPathIfSGML(view): # use cache of previous first selection if it exists global previous_first_selection prev = previous_first_selection[view.id()] - + current_first_sel = view.sel()[0] nodes = [] if prev is not None and regionIntersects(prev[0], sublime.Region(current_first_sel.begin(), current_first_sel.begin()), False): # current first selection matches xpath region from previous first selection @@ -275,7 +275,7 @@ def updateStatusToCurrentXPathIfSGML(view): result = results[0] previous_first_selection[view.id()] = (sublime.Region(result[2], result[3]), result[0]) # cache node and xpath region nodes.append(result[0]) - + # calculate xpath of node xpaths = getXPathOfNodes(nodes, None) if len(xpaths) == 1: @@ -283,14 +283,14 @@ def updateStatusToCurrentXPathIfSGML(view): intro = 'XPath' if len(view.sel()) > 1: intro = intro + ' (at first selection)' - + text = intro + ': ' + xpath maxLength = 234 # if status message is longer than this, sublime text 3 shows nothing in the status bar at all, so unfortunately we have to truncate it... if len(text) > maxLength: append = ' (truncated)' text = text[0:maxLength - len(append)] + append status = text - + if status is None: view.erase_status('xpath') else: @@ -301,13 +301,13 @@ def copyXPathsToClipboard(view, args): if isCursorInsideSGML(view): roots = ensureTreeCacheIsCurrent(view) if roots is not None: - + cursors = [] for result in getSGMLRegionsContainingCursors(view): cursors.append(result[2]) results = getNodesAtPositions(view, roots, cursors) paths = getXPathOfNodes([result[0] for result in results], args) - + if len(paths) > 0: sublime.set_clipboard(os.linesep.join(paths)) message = str(len(paths)) + ' xpath(s) copied to clipboard' @@ -323,12 +323,12 @@ class CopyXpathCommand(sublime_plugin.TextCommand): # example usage from python def run(self, edit, **args): """Copy XPath(s) at cursor(s) to clipboard.""" view = self.view - + copyXPathsToClipboard(view, args) - + def is_enabled(self, **args): return isCursorInsideSGML(self.view) - + def is_visible(self, **args): return containsSGML(self.view) @@ -340,15 +340,15 @@ class GotoRelativeCommand(sublime_plugin.TextCommand): def run(self, edit, **kwargs): # example usage from python console: sublime.active_window().active_view().run_command('goto_relative', {'direction': 'prev', 'goto_element': 'names'}) """Move cursor(s) to specified relative tag(s).""" view = self.view - + roots = ensureTreeCacheIsCurrent(view) if roots is not None: - + cursors = [] for result in getSGMLRegionsContainingCursors(view): cursors.append(result[2]) results = getNodesAtPositions(view, roots, cursors) - + new_nodes_under_cursors = [] allFound = True for result in results: @@ -358,7 +358,7 @@ def run(self, edit, **kwargs): # example usage from python console: sublime.acti break else: new_nodes_under_cursors.append(desired_node) - + if not allFound: message = kwargs['direction'] + ' node not found' if len(cursors) > 1: @@ -371,13 +371,13 @@ def run(self, edit, **kwargs): # example usage from python console: sublime.acti if 'goto_element' in kwargs: goto_element = kwargs['goto_element'] move_cursors_to_nodes(view, getUniqueItems(new_nodes_under_cursors), goto_element, None) - + def is_enabled(self, **kwargs): return isCursorInsideSGML(self.view) - + def is_visible(self): return containsSGML(self.view) - + def description(self, args): if args['direction'] == 'self': descr = 'tag' @@ -387,7 +387,7 @@ def description(self, args): descr = 'element' else: return None - + return 'Goto ' + args['direction'] + ' ' + descr def getBoolValueFromArgsOrSettings(key, args, default): @@ -409,14 +409,14 @@ def getUniqueItems(items): class XpathListener(sublime_plugin.EventListener): def on_selection_modified_async(self, view): updateStatusToCurrentXPathIfSGML(view) - + def on_activated_async(self, view): updateStatusToCurrentXPathIfSGML(view) - + def on_post_save_async(self, view): if getBoolValueFromArgsOrSettings('only_show_xpath_if_saved', None, False): updateStatusToCurrentXPathIfSGML(view) - + def on_pre_close(self, view): global change_counters global xml_roots @@ -426,7 +426,7 @@ def on_pre_close(self, view): xml_roots.pop(view.id(), None) xml_elements.pop(view.id(), None) previous_first_selection.pop(view.id(), None) - + if view.file_name() is None: # if the file has no filename associated with it if view.settings().get('xpath_test_file', None): return @@ -438,13 +438,13 @@ def on_pre_close(self, view): def register_xpath_extensions(): # http://lxml.de/extensions.html ns = etree.FunctionNamespace(None) - + def applyFuncToTextForItem(item, func): if isinstance(item, etree._Element): return func(item.xpath('string(.)')) else: return func(str(item)) - + # TODO: xpath 1 functions deal with lists by just taking the first node # - maybe we can provide optional arg to return nodeset by applying to all def applyTransformFuncToTextForItems(nodes, func): @@ -453,34 +453,34 @@ def applyTransformFuncToTextForItems(nodes, func): return [applyFuncToTextForItem(item, func) for item in nodes] else: return applyFuncToTextForItem(nodes, func) - + def applyFilterFuncToTextForItems(nodes, func): """If a nodeset is given, filter out items whose transformation function returns False. Otherwise, return the value from the predicate.""" if isinstance(nodes, list): return [item for item in nodes if applyFuncToTextForItem(item, func)] else: return applyFuncToTextForItem(nodes, func) - + def printValueAndReturnUnchanged(context, nodes, title = None): print_value = nodes if isinstance(nodes, list): if len(nodes) > 0 and isinstance(nodes[0], etree._Element): paths = getExactXPathOfNodes(nodes) print_value = paths - + if title is None: title = '' else: title = title + ':' print('XPath:', title, 'context_node', getExactXPathOfNodes([context.context_node])[0], 'eval_context', context.eval_context, 'values', print_value) return nodes - + ns['upper-case'] = lambda context, nodes: applyTransformFuncToTextForItems(nodes, str.upper) ns['lower-case'] = lambda context, nodes: applyTransformFuncToTextForItems(nodes, str.lower) ns['ends-with'] = lambda context, nodes, ending: applyFilterFuncToTextForItems(nodes, lambda item: item.endswith(ending)) #ns['trim'] = lambda context, nodes: applyTransformFuncToTextForItems(nodes, str.strip) # according to the XPath 1.0 spec, the built in normalize-space function will trim the text on both sides, making this unnecessary http://www.w3.org/TR/xpath/#function-normalize-space ns['print'] = printValueAndReturnUnchanged - + def xpathRegexFlagsToPythonRegexFlags(xpath_regex_flags): flags = 0 if 's' in xpath_regex_flags: @@ -491,9 +491,9 @@ def xpathRegexFlagsToPythonRegexFlags(xpath_regex_flags): flags = flags | re.IGNORECASE if 'x' in xpath_regex_flags: flags = flags | re.VERBOSE - + return flags - + ns['tokenize'] = lambda context, item, pattern, xpath_regex_flags = None: applyFuncToTextForItem(item, lambda text: re.split(pattern, text, maxsplit = 0, flags = xpathRegexFlagsToPythonRegexFlags(xpath_regex_flags))) ns['matches'] = lambda context, item, pattern, xpath_regex_flags = None: applyFuncToTextForItem(item, lambda text: re.search(pattern, text, flags = xpathRegexFlagsToPythonRegexFlags(xpath_regex_flags)) is not None) # replace @@ -503,7 +503,7 @@ def xpathRegexFlagsToPythonRegexFlags(xpath_regex_flags): # abs # ? adjust-dateTime-to-timezone, current-dateTime, day-from-dateTime, month-from-dateTime, days-from-duration, months-from-duration, etc. # insert-before, remove, subsequence, index-of, distinct-values, reverse, unordered, empty, exists - + def plugin_loaded(): """When the plugin is loaded, clear all variables and cache xpaths for current view if applicable.""" @@ -512,7 +512,7 @@ def plugin_loaded(): settings.clear_on_change('reparse') settings.add_on_change('reparse', settingsChanged) sublime.set_timeout_async(settingsChanged, 10) - + register_xpath_extensions() def plugin_unloaded(): @@ -529,7 +529,7 @@ def get_results_for_xpath_query_multiple_trees(query, tree_contexts, root_namesp variables = settings.get('variables', {}) for key in additional_variables: variables[key] = additional_variables[key] - + for tree in tree_contexts.keys(): namespaces = root_namespaces.get(tree.getroot(), {}) variables['contexts'] = tree_contexts[tree] @@ -537,9 +537,9 @@ def get_results_for_xpath_query_multiple_trees(query, tree_contexts, root_namesp if len(tree_contexts[tree]) > 0: context = tree_contexts[tree][0] matches += get_results_for_xpath_query(query, tree, context, namespaces, **variables) - + return matches - + def get_xpath_query_history_for_keys(keys): """Return all previously used xpath queries with any of the given keys, in order. If keys is None, return history across all keys.""" history_settings = sublime.load_settings('xpath_query_history.sublime-settings') @@ -555,7 +555,7 @@ def remove_item_from_xpath_query_history(key, query): history.remove(item) history_settings.set('history', history) #sublime.save_settings('xpath_query_history.sublime-settings') - + # def remove_key_from_xpath_query_history(key): # view_history = get_xpath_query_history_for_keys([key]) # for item in view_history: @@ -566,16 +566,16 @@ def add_to_xpath_query_history_for_key(key, query): """Add the specified query to the history for the given key.""" # if it exists in the history for the view already, move the item to the bottom (i.e. make it the most recent item in the history) by removing and re-adding it remove_item_from_xpath_query_history(key, query) - + history_settings = sublime.load_settings('xpath_query_history.sublime-settings') history = history_settings.get('history', []) history.append([query, key]) - + # if there are more than the specified maximum number of history items, remove the excess global settings max_history = settings.get('max_query_history', 100) history = history[-max_history:] - + history_settings.set('history', history) sublime.save_settings('xpath_query_history.sublime-settings') @@ -601,32 +601,32 @@ def get_history_key_for_view(view): class ShowXpathQueryHistoryCommand(sublime_plugin.TextCommand): history = None - + def run(self, edit, **args): global_history = getBoolValueFromArgsOrSettings('global_query_history', args, True) - + keys = None if not global_history: keys = [get_history_key_for_view(self.view)] - + self.history = get_xpath_query_history_for_keys(keys) if len(self.history) == 0: sublime.status_message('no query history to show') else: self.view.window().show_quick_panel(self.history, self.history_selection_done, 0, len(self.history) - 1, self.history_selection_changed) - + def history_selection_done(self, selected_index): if selected_index > -1: #add_to_xpath_query_history_for_key(get_history_key_for_view(self.view), self.history[selected_index]) sublime.active_window().active_view().run_command('query_xpath', { 'prefill_path_at_cursor': False, 'prefill_query': self.history[selected_index] }) - + def history_selection_changed(self, selected_index): if not getBoolValueFromArgsOrSettings('live_mode', None, True): self.history_selection_done(selected_index) - + def is_enabled(self, **args): return isCursorInsideSGML(self.view) - + def is_visible(self): return containsSGML(self.view) @@ -640,7 +640,7 @@ def namespace_map_from_contexts(contexts): root = node.getroottree().getroot() if root not in root_namespaces.keys(): root_namespaces[root] = namespace_map_for_tree(root.getroottree()) - + return root_namespaces def namespace_map_for_tree(tree): @@ -655,7 +655,7 @@ class SelectResultsFromXpathQueryCommand(sublime_plugin.TextCommand): # example def run(self, edit, **kwargs): contexts = get_context_nodes_from_cursors(self.view) nodes = get_results_for_xpath_query_multiple_trees(kwargs['xpath'], contexts, namespace_map_from_contexts(contexts)) - + global settings goto_element = settings.get('goto_element', 'open') goto_attribute = settings.get('goto_attribute', 'value') @@ -663,12 +663,12 @@ def run(self, edit, **kwargs): goto_element = 'open' if goto_attribute == 'none': goto_attribute = 'value' - + if 'goto_element' in kwargs: goto_element = kwargs['goto_element'] if 'goto_attribute' in kwargs: goto_attribute = kwargs['goto_attribute'] - + total_selectable_results, total_results = move_cursors_to_nodes(self.view, nodes, goto_element, goto_attribute) if total_selectable_results == total_results: sublime.status_message(str(total_results) + ' nodes selected') @@ -679,11 +679,11 @@ def run(self, edit, **kwargs): class RerunLastXpathQueryAndSelectResultsCommand(sublime_plugin.TextCommand): # example usage from python console: sublime.active_window().active_view().run_command('rerun_last_xpath_query_and_select_results', { 'global_query_history': False }) def run(self, edit, **args): global_history = getBoolValueFromArgsOrSettings('global_query_history', args, True) - + keys = [get_history_key_for_view(self.view)] if global_history: keys = None - + # TODO: preserve original $contexts variable (xpaths of all context nodes) with history, and restore here? history = get_xpath_query_history_for_keys(keys) if len(history) == 0: @@ -693,10 +693,10 @@ def run(self, edit, **args): args = dict() args['xpath'] = history[-1] self.view.run_command('select_results_from_xpath_query', args) - + def is_enabled(self, **args): return isCursorInsideSGML(self.view) - + def is_visible(self): return containsSGML(self.view) @@ -716,29 +716,29 @@ def run(self, edit, **args): self.view.erase_status('xpath_clean') sublime.status_message('Unable to find any SGML tag soup regions to fix.') return - + # clean all html regions specified, in reverse order, because otherwise the offsets will change after tidying the region before it! i.e. args['regions'] must be in ascending position order for region_tuple in reversed(args['regions']): region_scope = sublime.Region(region_tuple[0], region_tuple[1]) tag_soup = self.view.substr(region_scope) xml_string = clean_html(tag_soup) self.view.replace(edit, region_scope, xml_string) - + self.view.erase_status('xpath_clean') sublime.status_message('Tag soup cleaned successfully.') - + def is_enabled(self, **args): return isCursorInsideSGML(self.view) - + def is_visible(self): return containsSGML(self.view) def get_context_nodes_from_cursors(view): """Get nodes under the cursors for the specified view.""" roots = ensureTreeCacheIsCurrent(view) - + invalid_trees = [] - + regions_cursors = {} for result in getSGMLRegionsContainingCursors(view): if roots[result[1]] is None: @@ -747,7 +747,7 @@ def get_context_nodes_from_cursors(view): if isinstance(node, etree.CommentBase): node = node.getparent() regions_cursors.setdefault(result[1], []).append(node) - + if len(invalid_trees) > 0: invalid_trees = [region_scope for region_scope in invalid_trees if view.match_selector(region_scope.begin(), 'text.html - text.html.markdown')] if len(invalid_trees) > 0: @@ -757,9 +757,9 @@ def get_context_nodes_from_cursors(view): roots = ensureTreeCacheIsCurrent(view) updateStatusToCurrentXPathIfSGML(view) invalid_trees = [] - + contexts = {} - + if len(invalid_trees) > 0: # show error if any of the XML regions containing the cursor is invalid sublime.error_message('The XML cannot be parsed, therefore it is not currently possible to execute XPath queries on the document. Please see the status bar for parsing errors.') else: @@ -767,28 +767,28 @@ def get_context_nodes_from_cursors(view): root = roots[region_index] if root is not None: contexts[root.getroottree()] = [item[0] for item in getNodesAtPositions(view, [root], regions_cursors[region_index])] - + return contexts class QueryXpathCommand(QuickPanelFromInputCommand): # example usage from python console: sublime.active_window().active_view().run_command('query_xpath', { 'prefill_query': '//prefix:LocalName', 'live_mode': True }) max_results_to_show = None contexts = None previous_input = None # remember previous query so that when the user next runs this command, it will be prepopulated - + def cache_context_nodes(self): """Cache context nodes to allow live mode to work with them.""" context_nodes = get_context_nodes_from_cursors(self.view) change_count = self.view.change_count() - + different_tree = self.contexts is None or self.contexts[0] != change_count # if the document has changed since the context nodes were cached self.contexts = (change_count, context_nodes, namespace_map_from_contexts(context_nodes)) - + tree_count = 0 for root in context_nodes: tree_count += 1 - + print('XPath: context nodes: ', getExactXPathOfNodes(context_nodes[root])) - + if tree_count == 1: # if there is exactly one xml tree tree = next(iter(context_nodes.keys())) # get the tree if len(context_nodes[tree]) == 0: # if there are no context nodes @@ -799,13 +799,13 @@ def cache_context_nodes(self): if different_tree and isinstance(self.highlighted_result, LocationAwareElement): self.highlighted_result = context_nodes[tree][0] self.highlighted_index = 0 - + def run(self, edit, **args): self.cache_context_nodes() if len(self.contexts[1].keys()) == 0: # if there are no context nodes, don't proceed to show the xpath input panel return super().run(edit, **args) - + def parse_args(self): self.arguments['initial_value'] = self.get_value_from_args('prefill_query', self.previous_input) if self.arguments['initial_value'] is None: @@ -814,7 +814,7 @@ def parse_args(self): if global_history: keys = None history = get_xpath_query_history_for_keys(keys) - + if len(history) > 0: self.arguments['initial_value'] = history[-1] # if previous input is blank, or specifically told to, use path of first cursor. even if live mode enabled, cursor won't move much when activating this command @@ -824,29 +824,29 @@ def parse_args(self): if prev is not None: xpaths = getExactXPathOfNodes([prev[1]]) # ensure the path matches this node and only this node self.arguments['initial_value'] = xpaths[0] - + self.arguments['label'] = 'enter xpath' self.arguments['syntax'] = 'xpath.sublime-syntax' - + global settings self.max_results_to_show = int(self.get_value_from_args('max_results_to_show', settings.get('max_results_to_show', 1000))) - + self.arguments['async'] = getBoolValueFromArgsOrSettings('live_query_async', self.arguments, True) self.arguments['delay'] = int(settings.get('live_query_delay', 0)) self.arguments['live_mode'] = getBoolValueFromArgsOrSettings('live_mode', self.arguments, True) - + self.arguments['normalize_whitespace_in_preview'] = getBoolValueFromArgsOrSettings('normalize_whitespace_in_preview', self.arguments, False) self.arguments['auto_completion_triggers'] = settings.get('auto_completion_triggers', '/') self.arguments['intelligent_auto_complete'] = getBoolValueFromArgsOrSettings('intelligent_auto_complete', self.arguments, True) - - + + if 'goto_element' not in self.arguments: self.arguments['goto_element'] = settings.get('goto_element', 'open') if 'goto_attribute' not in self.arguments: self.arguments['goto_attribute'] = settings.get('goto_attribute', 'value') - + super().parse_args() - + def get_query_results(self, query): results = None status_text = None @@ -855,7 +855,7 @@ def get_query_results(self, query): else: if self.contexts[0] != self.view.change_count(): # if the document has changed since the context nodes were cached self.cache_context_nodes() - + try: results = list((result for result in get_results_for_xpath_query_multiple_trees(query, self.contexts[1], self.contexts[2])))# if not isinstance(result, etree.CommentBase))) except etree.XPathError as e: @@ -864,7 +864,7 @@ def get_query_results(self, query): print('XPath: exception evaluating results for "' + query + '": ' + repr(e)) #traceback.print_tb(e.__traceback__) status_text = e.__class__.__name__ + ': ' + str(e) - + if status_text is None: # if there was no error status_text = str(len(results)) + ' result' if len(results) != 1: @@ -875,30 +875,30 @@ def get_query_results(self, query): results = results[0:self.max_results_to_show] self.view.set_status('xpath_query', status_text or '') return results - + def get_items_from_input(self): return self.get_query_results(self.current_value) - + def get_items_to_show_in_quickpanel(self): results = self.items if results is None: return None - + # truncate each xml result at 70 chars so that it appears (more) correctly in the quick panel maxlen = 70 - + show_text_preview = None if self.arguments['normalize_whitespace_in_preview']: show_text_preview = lambda result: collapseWhitespace(str(result), maxlen) else: show_text_preview = lambda result: str(result)[0:maxlen] - + unique_types_in_result = getUniqueItems((type(item) for item in results)) next(unique_types_in_result, None) muliple_types_in_result = next(unique_types_in_result, None) is not None - + show_element_preview = lambda e: [getTagName(e)[2], collapseWhitespace(e.text, maxlen), getElementXMLPreview(self.view, e, maxlen)] - + def show_preview(item): if isinstance(item, etree.ElementBase) and not isinstance(item, etree.CommentBase): return show_element_preview(item) @@ -907,9 +907,9 @@ def show_preview(item): if muliple_types_in_result: # if some items are elements (where we show 3 lines) and some are other node types (where we show 1 line), we need to return 3 lines to ensure Sublime will show the results correctly show = [show, '', ''] return show - + return [show_preview(item) for item in results] - + def quickpanel_selection_changed(self, selected_index): super().quickpanel_selection_changed(selected_index) if selected_index > -1: # quick panel wasn't cancelled @@ -918,15 +918,15 @@ def quickpanel_selection_changed(self, selected_index): #self.view.window().focus_view(self.view) # focus the view to try getting the cursor positions to update while the quick panel is open #if self.input_panel is not None: # self.input_panel.window().focus_view(self.input_panel) - + def commit_input(self): self.previous_input = self.current_value add_to_xpath_query_history_for_key(get_history_key_for_view(self.view), self.current_value) - + def command_complete(self, cancelled): self.view.erase_status('xpath_query') super().command_complete(cancelled) - + def show_input_panel(self, initial_value): super().show_input_panel(initial_value) settings = self.input_panel.settings() @@ -934,37 +934,49 @@ def show_input_panel(self, initial_value): settings.set('auto_complete_include_snippets_when_typing', False) if len(self.arguments['auto_completion_triggers'] or '') > 0: settings.set('auto_complete_triggers', [ {'selector': 'query.xml.xpath - string', 'characters': self.arguments['auto_completion_triggers']} ]) - + def on_query_completions(self, prefix, locations): # moved from .sublime-completions file here - https://github.com/SublimeTextIssues/Core/issues/819 flags = sublime.INHIBIT_WORD_COMPLETIONS if not self.arguments['intelligent_auto_complete']: flags = 0 return (completions_for_xpath_query(self.input_panel, prefix, locations, self.contexts[1], self.contexts[2], settings.get('variables', {}), self.arguments['intelligent_auto_complete']), flags) - + def on_completion_committed(self): # show the auto complete popup again if the item that was autocompleted ended in a character that is an auto completion trigger for cursor in self.input_panel.sel(): prev_char = self.input_panel.substr(cursor.begin() - 1) if prev_char not in self.arguments['auto_completion_triggers']: return - + self.input_panel.run_command('auto_complete') self.input_panel.window().focus_view(self.input_panel) - + def is_enabled(self, **args): return isCursorInsideSGML(self.view) - + def is_visible(self): return containsSGML(self.view) def completions_for_xpath_query(view, prefix, locations, contexts, namespaces, variables, intelligent): def completions_axis_specifiers(): completions = ['ancestor', 'ancestor-or-self', 'attribute', 'child', 'descendant', 'descendant-or-self', 'following', 'following-sibling', 'namespace', 'parent', 'preceding', 'preceding-sibling', 'self'] - return [(completion + '\taxis', completion + '::') for completion in completions] + return [ + sublime.CompletionItem.snippet_completion( + completion, + completion + '::', + annotation='axis', + kind=sublime.KIND_NAVIGATION + ) for completion in completions] def completions_node_types(): completions = ['text', 'node', 'comment', 'processing-instruction'] - return [(completion + '\tnode type', completion + '()') for completion in completions] + return [ + sublime.CompletionItem.snippet_completion( + completion, + completion + '()', + annotation='node type', + kind=sublime.KIND_TYPE + ) for completion in completions] def completions_functions(): funcs = { @@ -977,13 +989,24 @@ def completions_functions(): } for key in funcs.keys(): for completion in funcs[key]: - yield (completion + '\t' + key + ' functions', completion + '($1)') - + yield sublime.CompletionItem.snippet_completion( + completion, + completion + '($1)', + annotation=key, + kind=sublime.KIND_FUNCTION + ) + completions = [] - + variables['contexts'] = None - variable_completions = [[key + '\tvariable', key] for key in sorted(variables.keys()) if key.startswith(prefix)] - + variable_completions = [ + sublime.CompletionItem.snippet_completion( + key, + key, + annotation='variable', + kind=sublime.KIND_VARIABLE + ) for key in sorted(variables.keys()) if key.startswith(prefix)] + prev_chars = [] positions = [] for location in locations: @@ -994,10 +1017,10 @@ def completions_functions(): prev_char = view.substr(pos - 1) if prev_char not in prev_chars: prev_chars.append(prev_char) - + if len(positions) == 0: return None # no locations suitable for suggestions - + include_generics = False include_xpath = False if len(prev_chars) == 1: @@ -1008,42 +1031,42 @@ def completions_functions(): include_generics = True if prev_chars[0] == '@': # if user is typing an attribute include_generics = False - + if include_generics or include_xpath: subqueries = parse_xpath_query_for_completions(view, positions[0]) last_location_step = subqueries[-1].split('/')[-1] - + if include_xpath and intelligent: # analyse relevant part of xpath query, and guess what user might want to type, i.e. suggest attributes that are present on the relevant elements when prefix starts with '@' etc. # execute previous complete query parts, so that we have the right context nodes for the current sub-expression - + if contexts is not None: # execute an xpath query to get all possible values exec_query = subqueries[-1] + '*' if prefix != '': exec_query += '[starts-with(name(), $_prefix)]' - + # determine if any queries can be skipped, due to using an absolute path relevant_queries = 0 for subquery in reversed(subqueries[0:-1]): relevant_queries += 1 if subquery != '' and subquery[0] in ('/', '$'): break - + start_index = len(subqueries) - relevant_queries - 1 subqueries = subqueries[start_index:] - + #print('XPath: completion context queries:', subqueries[0:-1], 'completion query:', exec_query, 'prefix:', prefix) - + # TODO: check all trees, not just the first one tree = list(contexts.keys())[0] completion_contexts = contexts[tree] - + xpath_variables = variables.copy() xpath_variables['contexts'] = contexts[tree] xpath_variables['expression_contexts'] = None xpath_variables['_prefix'] = prefix - + for query in subqueries[0:-1] + [exec_query]: if query != '': if query[0] not in ('$', '/', '('): @@ -1056,7 +1079,7 @@ def completions_functions(): completion_contexts = None print('XPath: exception obtaining completions for subquery "' + query + '": ' + repr(e)) break - + if completion_contexts is not None: for result in completion_contexts: if isinstance(result, etree._Element): # if it is an Element, add a completion with the full name of the element @@ -1070,7 +1093,14 @@ def completions_functions(): completion = fullname else: completion = localname - completions.append((fullname + '\tElement', completion)) + completions.append( + sublime.CompletionItem.snippet_completion( + fullname, + fullname, + annotation='Element', + kind=sublime.KIND_MARKUP + ) + ) elif isinstance(result, etree._ElementUnicodeResult): # if it is an attribute, add a completion with the name of the attribute if result.is_attribute: q = etree.QName(result.attrname) @@ -1078,13 +1108,20 @@ def completions_functions(): if q.namespace is not None: root = result.getparent().getroottree().getroot() attrname = next((nsprefix for nsprefix in namespaces[root].keys() if namespaces[root][nsprefix][0] == q.namespace)) + ':' + attrname # find the first prefix in the map that relates to this uri - completions.append((attrname + '\tAttribute', attrname)) # NOTE: can get the value with: result.getparent().get(result.attrname) - in case we ever want to do something fancy like suggest possible values when doing `@attr = *autocomplete*` etc. + completions.append( + sublime.CompletionItem.snippet_completion( + attrname, + attrname, + annotation='Attribute', + kind=sublime.KIND_MARKUP + ) + ) # NOTE: can get the value with: result.getparent().get(result.attrname) - in case we ever want to do something fancy like suggest possible values when doing `@attr = *autocomplete*` etc. else: # debug, are we missing something we could suggest? #completions.append((str(result) + '\t' + str(type(result)), str(result))) pass - + completions = list(getUniqueItems(completions)) - + if include_generics: generics = [] if ':' not in last_location_step: # if no namespace or axis operator used in the last location step of the subquery @@ -1092,8 +1129,14 @@ def completions_functions(): generics += completions_node_types() if subqueries[-1] == '': # XPath 1.0 functions and variables can only be used at the beginning of a sub-expression generics += list(completions_functions()) - generics += [('$' + item[0], '\\$' + item[1]) for item in variable_completions] # add possible variables - - completions += [completion for completion in generics if completion[0].startswith(last_location_step)] - + generics += [ + sublime.CompletionItem.snippet_completion( + '$' + item[0], + '\\$' + item[1], + annotation='variable', + kind=sublime.KIND_VARIABLE + ) for item in variable_completions] # add possible variables + + completions += [completion for completion in generics if completion.trigger.startswith(last_location_step)] + return completions From a0212f15b2b2f96ee8cbe16cb1335dc1ca155dae Mon Sep 17 00:00:00 2001 From: Keith Hall Date: Wed, 6 Nov 2024 21:02:43 +0200 Subject: [PATCH 4/7] opt in to Python 3.8 plugin host to get XPath working on newer Macs --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..cc1923a --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8 From 5ec606464169676388e38c33032aae7cc8f5e8dd Mon Sep 17 00:00:00 2001 From: Keith Hall Date: Wed, 6 Nov 2024 21:02:56 +0200 Subject: [PATCH 5/7] fix some bugs discovered on the way --- lxml_parser.py | 5 +++++ xpath.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lxml_parser.py b/lxml_parser.py index c1d52b9..6e18eb0 100644 --- a/lxml_parser.py +++ b/lxml_parser.py @@ -158,6 +158,11 @@ def element_start(self, tag, attrib=None, nsmap=None, location=None): def create_element(self, tag, attrib=None, nsmap=None): LocationAwareElement.TAG = tag + if nsmap: # a change made in lxml 3.8.0 / 3.5.0b1 requires us to pass None instead of an empty prefix string + if '' in nsmap: + nsmap[None] = nsmap[''] + del nsmap[''] + return LocationAwareElement(attrib=attrib, nsmap=nsmap) def element_end(self, tag, location=None): diff --git a/xpath.py b/xpath.py index 3b240d5..ced9557 100644 --- a/xpath.py +++ b/xpath.py @@ -32,7 +32,7 @@ def settingsChanged(): def getSGMLRegions(view): """Find all xml and html scopes in the specified view.""" global settings - return view.find_by_selector(settings.get('sgml_selector')) + return view.find_by_selector(settings.get('sgml_selector', 'text.xml')) def containsSGML(view): """Return True if the view contains XML or HTML syntax.""" @@ -263,7 +263,7 @@ def updateStatusToCurrentXPathIfSGML(view): else: # use cache of previous first selection if it exists global previous_first_selection - prev = previous_first_selection[view.id()] + prev = previous_first_selection.get(view.id(), None) current_first_sel = view.sel()[0] nodes = [] From edea4b2d6a69a960b6b8213ddfca4c93885257a5 Mon Sep 17 00:00:00 2001 From: Keith Hall Date: Wed, 6 Nov 2024 21:18:13 +0200 Subject: [PATCH 6/7] add instructions how to get lxml working on a Mac --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index d674b6a..dafad45 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,37 @@ The recommended way to install the Sublime Text XPath plugin is via [Package Con ## Troubleshooting +## Context menu items disabled + +This can happen if the `lxml` dependency didn't load properly. You'll see errors in the ST console. + +### Mac +On a Mac with Apple silicon, the version of lxml installed by Package Control 4 doesn't seem to work. +In ST console, we can see that ST build 4180 is using Python 3.8.12: +```python +import sys; sys.version_info +``` +So you can build lxml manually using this version of Python. You will need to download the source code release asset, as lxml's git repository doesn't contain some `.c` files which are required to build. lxml v5.1.1 for sure works: +- Navigate to https://github.com/lxml/lxml/releases/tag/lxml-5.1.1 +- Download `lxml-5.1.1.tar.gz` +- extract it + +```sh +brew install pyenv +pyenv init # follow instructions + +pyenv install 3.8.12 +pyenv shell 3.8.12 + +cd ~/Downloads/lxml-5.1.1 +python setup.py build +``` + +- then copy `~/Downloads/lxml-5.1.1/build/lib.macosx-15.1-arm64-3.8/lxml` into `~/Library/Application Support/Sublime Text/Lib/python38/lxml`, overwriting anything already there. +- Restart ST. + +(If you were to try downloading `lxml-5.1.1-cp38-cp38-macosx_10_9_universal2.whl` for example, and extracting that into ST's lib folder mentioned above, when you restart ST, you would be told that an `.so` file in `Lib/python38/lxml/` folder isn't trusted, and there would be no option to "allow". You could go in Mac Settings -> Privacy and Security and it should show up there with an option to allow it. But you'd still see that the `lxml` dependency fails to load in ST, and the only solution seems to be building from source on the Mac.) + ### CDATA Nodes When working with XML documents, you are probably used to the Document Object Model (DOM), where CDATA nodes are separate to text nodes. XPath sees `text()` nodes as all adjacent CDATA and text node siblings together. From 9e72e1dda5ddce6e3a6a22cd668ae07b646f03e7 Mon Sep 17 00:00:00 2001 From: Keith Hall Date: Wed, 6 Nov 2024 22:18:57 +0200 Subject: [PATCH 7/7] more fixes --- lxml_parser.py | 12 ++++-------- xpath.py | 16 ++++++++++------ xpath.sublime-settings | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lxml_parser.py b/lxml_parser.py index 4b8ced7..d4355e1 100644 --- a/lxml_parser.py +++ b/lxml_parser.py @@ -304,16 +304,12 @@ def unique_namespace_prefixes(namespaces, replaceNoneWith = 'default', start = 1 def get_results_for_xpath_query(query, tree, context = None, namespaces = None, **variables): """Given a query string and a document trees and optionally some context elements, compile the xpath query and execute it.""" - nsmap = {} - if namespaces is not None: + nsmap = dict() + if namespaces: for prefix in namespaces.keys(): - if namespaces[prefix][0] != '': - nsmap[prefix] = namespaces[prefix][0] + nsmap[prefix] = namespaces[prefix][0] - try: - xpath = etree.XPath(query, namespaces = nsmap) - except Exception as e: - raise ValueError(query) from e + xpath = etree.XPath(query, namespaces = nsmap) results = execute_xpath_query(tree, xpath, context, **variables) return results diff --git a/xpath.py b/xpath.py index 9f37c31..ecd88d5 100644 --- a/xpath.py +++ b/xpath.py @@ -142,7 +142,7 @@ def getXPathOfNodes(nodes, args): include_indexes = not getBoolValueFromArgsOrSettings('show_hierarchy_only', args, False) unique = getBoolValueFromArgsOrSettings('copy_unique_path_only', args, True) include_attributes = include_indexes or getBoolValueFromArgsOrSettings('show_attributes_in_hierarchy', args, False) - show_namespace_prefixes_from_query = getBoolValueFromArgsOrSettings('show_namespace_prefixes_from_query', args, False) + show_namespace_prefixes_from_query = getBoolValueFromArgsOrSettings('show_namespace_prefixes_from_query', args, True) case_sensitive = getBoolValueFromArgsOrSettings('case_sensitive', args, True) all_attributes = getBoolValueFromArgsOrSettings('show_all_attributes', args, False) @@ -154,7 +154,7 @@ def getXPathOfNodes(nodes, args): def getTagNameWithMappedPrefix(node, namespaces): tag = getTagName(node) if show_namespace_prefixes_from_query and tag[0] is not None: # if the element belongs to a namespace - unique_prefix = next((prefix for prefix in namespaces.keys() if namespaces[prefix] == (tag[0], node.prefix)), None) # find the first prefix in the map that relates to this uri + unique_prefix = next((prefix for prefix in namespaces.keys() if namespaces[prefix] == (tag[0], node.prefix or '')), None) # find the first prefix in the map that relates to this uri if unique_prefix is not None: tag = (tag[0], tag[1], unique_prefix + ':' + tag[1]) # ensure that the path we display can be used to query the element @@ -858,10 +858,11 @@ def get_query_results(self, query): try: results = list((result for result in get_results_for_xpath_query_multiple_trees(query, self.contexts[1], self.contexts[2])))# if not isinstance(result, etree.CommentBase))) - except etree.XPathError as e: + except (ValueError, etree.XPathError) as e: last_char = query.rstrip()[-1] if not last_char in ('/', ':', '@', '[', '(', ','): # log exception to console only if might be useful print('XPath: exception evaluating results for "' + query + '": ' + repr(e)) + #print(e.error_log) #traceback.print_tb(e.__traceback__) status_text = e.__class__.__name__ + ': ' + str(e) @@ -1075,7 +1076,7 @@ def completions_functions(): try: completion_contexts = get_results_for_xpath_query(query, tree, None, namespaces[tree.getroot()], **xpath_variables) # TODO: if result is not a node, break out as we can't offer any useful suggestions (currently we just get an exception: Non-Element values not supported at this point - got 'example string') when it tries $expression_contexts/* - except etree.XPathError as e: # xpath query invalid, just show static contexts + except (ValueError, etree.XPathError) as e: # xpath query invalid, just show static contexts completion_contexts = None print('XPath: exception obtaining completions for subquery "' + query + '": ' + repr(e)) break @@ -1087,8 +1088,11 @@ def completions_functions(): ns_prefix = '' if ns is not None: # ensure we get the prefix that we have mapped to the namespace for the query root = result.getroottree().getroot() - ns_prefix = next((nsprefix for nsprefix in namespaces[root].keys() if namespaces[root][nsprefix] == (ns, result.prefix))) # find the first prefix in the map that relates to this uri - fullname = ns_prefix + ':' + localname + ns_prefix = next((nsprefix for nsprefix in namespaces[root].keys() if namespaces[root][nsprefix] == (ns, result.prefix or '')), None) # find the first prefix in the map that relates to this uri + if ns_prefix: + fullname = ns_prefix + ':' + localname + else: + print('XPath warning: unable to find', ns, result.prefix, ' in root namespaces', namespaces[root], 'while generating completions') if not last_location_step.endswith(':') or last_location_step.endswith('::') or last_location_step.endswith(ns_prefix + ':'): # ensure `prefix :` works correctly and also `different_prefix_to_suggestion:` (note that we don't do this for attributes - attributes are not allowed spaces before the colon, and if the prefix differs when there is no space, Sublime will replace it with the completion anyway) completion = fullname else: diff --git a/xpath.sublime-settings b/xpath.sublime-settings index feee3ef..993c11d 100644 --- a/xpath.sublime-settings +++ b/xpath.sublime-settings @@ -21,7 +21,7 @@ // default namespace prefix for xpath query when elements have xmlns attribute set "default_namespace_prefix": "default", // whether or not to show the namespace prefix that the xpath query will expect - "show_namespace_prefixes_from_query": false, + "show_namespace_prefixes_from_query": true, // whether or not to only show the current xpath in the status bar if the view is not dirty. Useful to save CPU cycles when editing a document "only_show_xpath_if_saved": false, // only show the first x number of results from the xpath query, to speed up result display. Set to <= 0 for no limit