From 6a92fa96f214d7263f343d9909719a2322b6181d Mon Sep 17 00:00:00 2001 From: Josh Johnson Date: Tue, 29 May 2018 21:38:11 -0400 Subject: [PATCH] Add Get Current XPath This commit also moves some shared code to a common class. Issue: #85 --- package.json | 8 ++ src/common/index.ts | 1 + src/common/xml-traverser.ts | 129 ++++++++++++++++++++++++ src/constants.ts | 1 + src/extension.ts | 5 +- src/tree-view/xml-tree-data-provider.ts | 99 ++++-------------- src/xpath/commands/getCurrentXPath.ts | 21 ++++ src/xpath/commands/index.ts | 1 + src/xpath/index.ts | 1 + src/xpath/xpath-builder.ts | 41 ++++++++ 10 files changed, 223 insertions(+), 84 deletions(-) create mode 100644 src/common/xml-traverser.ts create mode 100644 src/xpath/commands/getCurrentXPath.ts create mode 100644 src/xpath/xpath-builder.ts diff --git a/package.json b/package.json index 0c7d313..62eeb1c 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,10 @@ "command": "xmlTools.formatAsXml", "title": "XML Tools: Format as XML" }, + { + "command": "xmlTools.getCurrentXPath", + "title": "XML Tools: Get Current XPath" + }, { "command": "xmlTools.minifyXml", "title": "XML Tools: Minify XML" @@ -195,6 +199,10 @@ "command": "xmlTools.executeXQuery", "when": "editorLangId == xquery" }, + { + "command": "xmlTools.getCurrentXPath", + "when": "editorLangId == xml" + }, { "command": "xmlTools.minifyXml", "when": "editorLangId == xml" diff --git a/src/common/index.ts b/src/common/index.ts index 3089050..f581696 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -2,3 +2,4 @@ export * from "./configuration"; export * from "./create-document-selector"; export * from "./extension-state"; export * from "./native-commands"; +export * from "./xml-traverser"; diff --git a/src/common/xml-traverser.ts b/src/common/xml-traverser.ts new file mode 100644 index 0000000..9273b77 --- /dev/null +++ b/src/common/xml-traverser.ts @@ -0,0 +1,129 @@ +import { Position } from "vscode"; +import { DOMParser } from "xmldom"; + +export class XmlTraverser { + + constructor(private _xmlDocument: Document) { } + + get xmlDocument(): Document { + return this._xmlDocument; + } + + set xmlDocument(value: Document) { + this._xmlDocument = value; + } + + getChildAttributeArray(node: Element): any[] { + if (!node.attributes) { + return []; + } + + const array = new Array(); + + for (let i = 0; i < node.attributes.length; i++) { + array.push(node.attributes[i]); + } + + return array; + } + + getChildElementArray(node: Node): any[] { + if (!node.childNodes) { + return []; + } + + const array = new Array(); + + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + + if (this.isElement(child)) { + array.push(child); + } + } + + return array; + } + + getElementAtPosition(position: Position): Element { + const node = this.getNodeAtPosition(position); + + return this.getNearestElementAncestor(node); + } + + getNearestElementAncestor(node: Node): Element { + if (!this.isElement) { + return this.getNearestElementAncestor(node.parentNode); + } + + return node; + } + + getNodeAtPosition(position: Position): Node { + return this._getNodeAtPositionCore(position, this._xmlDocument.documentElement); + } + + getSiblings(node: Node): Node[] { + return [...this.getChildAttributeArray(node.parentNode), ...this.getChildElementArray(node.parentNode)]; + } + + hasSimilarSiblings(node: Node): boolean { + if (!node || !node.parentNode || !this.isElement(node)) { + return false; + } + + const siblings = this.getChildElementArray(node.parentNode); + + return (siblings.filter(x => x.tagName === (node as Element).tagName).length > 1); + } + + isElement(node: Node): boolean { + return (!!node && !!(node as Element).tagName); + } + + private _getNodeAtPositionCore(position: Position, contextNode: Node): Node { + if (!contextNode) { + return undefined; + } + + const lineNumber = (contextNode as any).lineNumber; + const columnNumber = (contextNode as any).columnNumber; + const columnRange = [columnNumber, (columnNumber + (this._getNodeWidthInCharacters(contextNode) - 1))]; + + // for some reason, xmldom sets the column number for attributes to the "=" + if (!this.isElement(contextNode)) { + columnRange[0] = (columnRange[0] - contextNode.nodeName.length); + } + + if (lineNumber === (position.line + 1) && ((position.character + 1) >= columnRange[0] && (position.character + 1) < columnRange[1])) { + return contextNode; + } + + if (this.isElement(contextNode)) { + const children = [...this.getChildAttributeArray(contextNode), ...this.getChildElementArray(contextNode)]; + let result: Node; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + + result = this._getNodeAtPositionCore(position, child); + + if (result) { + return result; + } + } + } + + return undefined; + } + + private _getNodeWidthInCharacters(node: Node) { + if (this.isElement(node)) { + return (node.nodeName.length + 2); + } + + else { + return (node.nodeName.length + node.nodeValue.length + 3); + } + } +} diff --git a/src/constants.ts b/src/constants.ts index 28ac902..6261aee 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,6 +2,7 @@ export namespace commands { export const evaluateXPath = "xmlTools.evaluateXPath"; export const executeXQuery = "xmlTools.executeXQuery"; export const formatAsXml = "xmlTools.formatAsXml"; + export const getCurrentXPath = "xmlTools.getCurrentXPath"; export const minifyXml = "xmlTools.minifyXml"; } diff --git a/src/extension.ts b/src/extension.ts index 276d9df..ee975af 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,7 +9,7 @@ import { XmlFormatterFactory, XmlFormattingEditProvider } from "./formatting"; import { formatAsXml, minifyXml } from "./formatting/commands"; import { XQueryLinter } from "./linting"; import { XmlTreeDataProvider } from "./tree-view"; -import { evaluateXPath } from "./xpath/commands"; +import { evaluateXPath, getCurrentXPath } from "./xpath/commands"; import { executeXQuery } from "./xquery-execution/commands"; import * as constants from "./constants"; @@ -61,7 +61,8 @@ export function activate(context: ExtensionContext) { /* XPath Features */ context.subscriptions.push( - commands.registerTextEditorCommand(constants.commands.evaluateXPath, evaluateXPath) + commands.registerTextEditorCommand(constants.commands.evaluateXPath, evaluateXPath), + commands.registerTextEditorCommand(constants.commands.getCurrentXPath, getCurrentXPath) ); /* XQuery Features */ diff --git a/src/tree-view/xml-tree-data-provider.ts b/src/tree-view/xml-tree-data-provider.ts index 82d25ae..106d0a1 100644 --- a/src/tree-view/xml-tree-data-provider.ts +++ b/src/tree-view/xml-tree-data-provider.ts @@ -7,12 +7,13 @@ import { import * as path from "path"; import { DOMParser } from "xmldom"; -import { Configuration, NativeCommands } from "../common"; +import { Configuration, NativeCommands, XmlTraverser } from "../common"; import * as constants from "../constants"; export class XmlTreeDataProvider implements TreeDataProvider { private _onDidChangeTreeData: EventEmitter = new EventEmitter(); private _xmlDocument: Document; + private _xmlTraverser: XmlTraverser; constructor(private _context: ExtensionContext) { window.onDidChangeActiveTextEditor(() => { @@ -38,13 +39,13 @@ export class XmlTreeDataProvider implements TreeDataProvider { const treeItem = new TreeItem(element.localName); - if (!this._isElement(element)) { + if (!this._xmlTraverser.isElement(element)) { treeItem.label = `${element.localName} = "${element.nodeValue}"`; } else if (enableMetadata) { - const childAttributes = this._getChildAttributeArray(element); - const childElements = this._getChildElementArray(element); + const childAttributes = this._xmlTraverser.getChildAttributeArray(element); + const childElements = this._xmlTraverser.getChildElementArray(element); const totalChildren = (childAttributes.length + childElements.length); if (totalChildren > 0) { @@ -64,7 +65,7 @@ export class XmlTreeDataProvider implements TreeDataProvider { treeItem.label += ")"; } - if (this._hasSimilarSiblings(element) && enableSync) { + if (this._xmlTraverser.hasSimilarSiblings(element) && enableSync) { treeItem.label += ` [line ${(element as any).lineNumber}]`; } } @@ -88,8 +89,8 @@ export class XmlTreeDataProvider implements TreeDataProvider { this._refreshTree(); } - if (this._isElement(element)) { - return [].concat(this._getChildAttributeArray(element), this._getChildElementArray(element)); + if (this._xmlTraverser.isElement(element)) { + return [].concat(this._xmlTraverser.getChildAttributeArray(element), this._xmlTraverser.getChildElementArray(element)); } else if (this._xmlDocument) { @@ -102,78 +103,21 @@ export class XmlTreeDataProvider implements TreeDataProvider { } getParent(element: Node): Node { - if (!element || !element.parentNode || !element.parentNode.parentNode) { + if ((!element || !element.parentNode || !element.parentNode.parentNode) && !(element as any).ownerElement) { return undefined; } - return element.parentNode; + return element.parentNode || (element as any).ownerElement; } getNodeAtPosition(position: Position): Node { - return this._getNodeAtPositionCore(position, this._xmlDocument.documentElement); - } - - private _getNodeAtPositionCore(position: Position, contextElement: Element): Node { - if (!contextElement) { - return undefined; - } - - if (((contextElement as any).lineNumber - 1) === position.line) { - return contextElement; - } - - const children = this._getChildElementArray(contextElement); - let result: Node; - - for (let i = 0; i < children.length; i++) { - const child = children[i]; - - result = this._getNodeAtPositionCore(position, child); - - if (result) { - return result; - } - } - - return undefined; - } - - private _getChildAttributeArray(node: Element): any[] { - if (!node.attributes) { - return []; - } - - const array = new Array(); - - for (let i = 0; i < node.attributes.length; i++) { - array.push(node.attributes[i]); - } - - return array; - } - - private _getChildElementArray(node: Element): any[] { - if (!node.childNodes) { - return []; - } - - const array = new Array(); - - for (let i = 0; i < node.childNodes.length; i++) { - const child = node.childNodes[i]; - - if (this._isElement(child)) { - array.push(child); - } - } - - return array; + return this._xmlTraverser.getNodeAtPosition(position); } private _getIcon(element: Node): any { let type = "element"; - if (!this._isElement(element)) { + if (!this._xmlTraverser.isElement(element)) { type = "attribute"; } @@ -185,20 +129,6 @@ export class XmlTreeDataProvider implements TreeDataProvider { return icon; } - private _hasSimilarSiblings(element: Element): boolean { - if (!element || !element.parentNode) { - return false; - } - - const siblings = this._getChildElementArray(element.parentNode); - - return (siblings.filter(x => x.tagName === element.tagName).length > 1); - } - - private _isElement(node: Node): boolean { - return (!!node && !!(node as Element).tagName); - } - private _refreshTree(): void { if (!this.activeEditor || this.activeEditor.document.languageId !== constants.languageIds.xml) { NativeCommands.setContext(constants.contextKeys.xmlTreeViewEnabled, false); @@ -227,6 +157,11 @@ export class XmlTreeDataProvider implements TreeDataProvider { this._xmlDocument = new DOMParser().parseFromString("", "text/xml"); } + finally { + this._xmlTraverser = this._xmlTraverser || new XmlTraverser(this._xmlDocument); + this._xmlTraverser.xmlDocument = this._xmlDocument; + } + this._onDidChangeTreeData.fire(); } diff --git a/src/xpath/commands/getCurrentXPath.ts b/src/xpath/commands/getCurrentXPath.ts new file mode 100644 index 0000000..23fdac5 --- /dev/null +++ b/src/xpath/commands/getCurrentXPath.ts @@ -0,0 +1,21 @@ +import { window } from "vscode"; +import { TextEditor, TextEditorEdit } from "vscode"; +import { DOMParser } from "xmldom"; + +import { XPathBuilder } from "../xpath-builder"; + +export function getCurrentXPath(editor: TextEditor, edit: TextEditorEdit): void { + if (!editor.selection) { + window.showInformationMessage("Please put your cursor in an element or attribute name."); + + return; + } + + const document = new DOMParser().parseFromString(editor.document.getText()); + const xpath = new XPathBuilder(document).build(editor.selection.start); + + window.showInputBox({ + value: xpath, + valueSelection: undefined + }); +} diff --git a/src/xpath/commands/index.ts b/src/xpath/commands/index.ts index d25f558..699f529 100644 --- a/src/xpath/commands/index.ts +++ b/src/xpath/commands/index.ts @@ -1 +1,2 @@ export * from "./evaluateXPath"; +export * from "./getCurrentXPath"; diff --git a/src/xpath/index.ts b/src/xpath/index.ts index 81dfa2a..f387a5a 100644 --- a/src/xpath/index.ts +++ b/src/xpath/index.ts @@ -1 +1,2 @@ +export * from "./xpath-builder"; export * from "./xpath-evaluator"; diff --git a/src/xpath/xpath-builder.ts b/src/xpath/xpath-builder.ts new file mode 100644 index 0000000..6efafc9 --- /dev/null +++ b/src/xpath/xpath-builder.ts @@ -0,0 +1,41 @@ +import { Position } from "vscode"; +import { DOMParser } from "xmldom"; + +import { XmlTraverser } from "../common"; + +export class XPathBuilder { + + private _xmlTraverser: XmlTraverser; + + constructor(private _xmlDocument: Document) { + this._xmlTraverser = new XmlTraverser(this._xmlDocument); + } + + build(position: Position): string { + const selectedNode = this._xmlTraverser.getNodeAtPosition(position); + + return this._buildCore(selectedNode); + } + + private _buildCore(selectedNode: Node): string { + if (selectedNode === this._xmlDocument.documentElement) { + return `/${selectedNode.nodeName}`; + } + + if (!this._xmlTraverser.isElement(selectedNode)) { + return `${this._buildCore((selectedNode as any).ownerElement)}/@${selectedNode.nodeName}`; + } + + else if (this._xmlTraverser.hasSimilarSiblings(selectedNode)) { + const siblings = this._xmlTraverser.getSiblings(selectedNode); + const xPathIndex = (siblings.indexOf(selectedNode) + 1); + + return `${this._buildCore(selectedNode.parentNode)}/${selectedNode.nodeName}[${xPathIndex}]`; + } + + else { + return `${this._buildCore(selectedNode.parentNode)}/${selectedNode.nodeName}`; + } + } + +}