From 1c083d15e3ffa8d59bf5d3c88736d16fdbdecfae Mon Sep 17 00:00:00 2001 From: Josh Johnson Date: Sat, 28 Apr 2018 00:10:51 -0400 Subject: [PATCH] Add XPath Evaluation --- README.md | 1 + package-lock.json | 5 ++ package.json | 1 + src/constants.ts | 8 +++ src/extension.ts | 13 +++- src/xpath/commands/evaluateXPath.ts | 104 ++++++++++++++++++++++++++++ src/xpath/xpath-evaluator.ts | 59 ++++++++++++++++ 7 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/xpath/commands/evaluateXPath.ts create mode 100644 src/xpath/xpath-evaluator.ts diff --git a/README.md b/README.md index 6bcb635..57924bc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ *In Progress* ## Extension Settings +* `xmlTools.enableXmlTreeView`: Enables the XML Tree View for XML documents. * `xmlTools.ignoreDefaultNamespace`: Ignore default xmlns attributes when evaluating XPath. * `xmlTools.persistXPathQuery`: Remember the last XPath query used. * `xmlTools.removeCommentsOnMinify`: Remove XML comments during minification. diff --git a/package-lock.json b/package-lock.json index 8b3ac2b..5752668 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3078,6 +3078,11 @@ "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=" }, + "xpath": { + "version": "0.0.27", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz", + "integrity": "sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==" + }, "xqlint": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/xqlint/-/xqlint-0.4.0.tgz", diff --git a/package.json b/package.json index f96e064..31b0fbd 100644 --- a/package.json +++ b/package.json @@ -188,6 +188,7 @@ }, "dependencies": { "xmldom": "^0.1.27", + "xpath": "0.0.27", "xqlint": "^0.4.0" } } diff --git a/src/constants.ts b/src/constants.ts index 552a47f..9616133 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,7 @@ export const extensionPrefix = "xmlTools"; export namespace commands { + export const evaluateXPath = "xmlTools.evaluateXPath"; export const setContext = "setContext"; } @@ -10,4 +11,11 @@ export namespace contextKeys { export namespace configKeys { export const enableXmlTreeView = "enableXmlTreeView"; + export const ignoreDefaultNamespace = "ignoreDefaultNamespace"; + export const persistXPathQuery = "persistXPathQuery"; +} + +export namespace stateKeys { + export const xpathQueryHistory = "xpathQueryHistory"; + export const xPathQueryLast = "xPathQueryLast"; } diff --git a/src/extension.ts b/src/extension.ts index 7b34170..4aacf73 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,5 @@ import { languages, window, workspace, commands } from "vscode"; -import { ExtensionContext, TextEditor, TextEditorSelectionChangeEvent, WorkspaceConfiguration } from "vscode"; +import { ExtensionContext, Memento, TextEditor, TextEditorSelectionChangeEvent, WorkspaceConfiguration } from "vscode"; import { XQueryCompletionItemProvider } from "./completion/xquery-completion-item-provider"; import { FormatAsXmlCommandName, formatAsXml } from "./formatting/commands/formatAsXml"; @@ -8,10 +8,16 @@ import { XmlFormatterFactory } from "./formatting/xml-formatter"; import { XmlFormattingEditProvider } from "./formatting/xml-formatting-edit-provider"; import { XQueryLinter } from "./linting/xquery-linter"; import { XmlTreeDataProvider } from "./tree-view/xml-tree-data-provider"; +import { evaluateXPath } from "./xpath/commands/evaluateXPath"; import * as constants from "./constants"; +export const ExtensionState: { global?: Memento, workspace?: Memento } = { }; + export function activate(context: ExtensionContext) { + ExtensionState.global = context.globalState; + ExtensionState.workspace = context.workspaceState; + const config = workspace.getConfiguration(constants.extensionPrefix); /* Completion Features */ @@ -39,6 +45,11 @@ export function activate(context: ExtensionContext) { context.subscriptions.push( window.registerTreeDataProvider("xmlTreeView", new XmlTreeDataProvider(context)) ); + + /* XPath Features */ + context.subscriptions.push( + commands.registerTextEditorCommand(constants.commands.evaluateXPath, evaluateXPath) + ); } export function deactivate() { diff --git a/src/xpath/commands/evaluateXPath.ts b/src/xpath/commands/evaluateXPath.ts new file mode 100644 index 0000000..4262467 --- /dev/null +++ b/src/xpath/commands/evaluateXPath.ts @@ -0,0 +1,104 @@ +import { window, workspace } from "vscode"; +import { TextEditor, TextEditorEdit, ViewColumn } from "vscode"; + +import * as constants from "../../constants"; +import { ExtensionState } from "../../extension"; + +import { EvaluatorResult, EvaluatorResultType, XPathEvaluator } from "../xpath-evaluator"; + +export async function evaluateXPath(editor: TextEditor, edit: TextEditorEdit): Promise { + const config = workspace.getConfiguration(constants.extensionPrefix); + + // if there is no workspace, we will track queries in the global Memento + const memento = ExtensionState.workspace || ExtensionState.global; + + // get the xpath persistence setting + const persistQueries = config.get(constants.configKeys.persistXPathQuery, true); + + // get the last query if there is one for this document + // if not, try pulling the last query ran, regardless of document + // NOTE: if the user has focus on the output channel when opening the xquery prompt, the channel is the "active" document + const history = memento.get(constants.stateKeys.xpathQueryHistory, new Array()); + const globalLastQuery = memento.get(constants.stateKeys.xPathQueryLast, ""); + + const lastQuery = history.find(x => { + return (x.uri === editor.document.uri.toString()); + }); + + // set the inital display value and prompt the user + let query = (lastQuery) ? lastQuery.query : globalLastQuery; + + query = await window.showInputBox({ + placeHolder: "XPath Query", + prompt: "Please enter an XPath query to evaluate.", + value: query + }); + + // showInputBox() will return undefined if the user dimissed the prompt + if (!query) { + return; + } + + const ignoreDefaultNamespace = config.get(constants.configKeys.ignoreDefaultNamespace, true); + + // run the query + const xml = editor.document.getText(); + let evalResult: EvaluatorResult; + + try { + evalResult = XPathEvaluator.evaluate(query, xml, ignoreDefaultNamespace); + } + + catch (error) { + console.error(error); + window.showErrorMessage(`Something went wrong while evaluating the XPath: ${error}`); + return; + } + + // show the results to the user + const outputChannel = window.createOutputChannel("XPath Results"); + + outputChannel.clear(); + + outputChannel.appendLine(`XPath Query: ${query}`); + outputChannel.append("\n"); + + if (evalResult.type === EvaluatorResultType.NODE_COLLECTION) { + (evalResult.result as Node[]).forEach((node: any) => { + outputChannel.appendLine(`[Line ${node.lineNumber}] ${node.localName}: ${node.textContent}`); + }); + } + + else { + outputChannel.appendLine(`[Result]: ${evalResult.result}`); + } + + outputChannel.show(ViewColumn.Three); + + if (persistQueries) { + const historicQuery = new HistoricQuery(editor.document.uri.toString(), query); + + const affectedIndex = history.findIndex(x => x.uri === historicQuery.uri); + + if (affectedIndex === -1) { + history.push(historicQuery); + } + + else { + history[affectedIndex].query = query; + } + + memento.update(constants.stateKeys.xpathQueryHistory, history); + memento.update(constants.stateKeys.xPathQueryLast, query); + } +} + +class HistoricQuery { + constructor(uri: string, query: string) { + this.uri = uri; + this.query = query; + } + + uri: string; + query: string; +} diff --git a/src/xpath/xpath-evaluator.ts b/src/xpath/xpath-evaluator.ts new file mode 100644 index 0000000..ae05414 --- /dev/null +++ b/src/xpath/xpath-evaluator.ts @@ -0,0 +1,59 @@ +import * as xpath from "xpath"; +import { SelectedValue, XPathSelect } from "xpath"; +import { DOMParser } from "xmldom"; + +export class EvaluatorResult { + type: EvaluatorResultType; + result: Node[] | number | string | boolean; +} + +export class EvaluatorResultType { + static SCALAR_TYPE = 0; + static NODE_COLLECTION = 1; +} + +export class XPathEvaluator { + static evaluate(query: string, xml: string, ignoreDefaultNamespace: boolean): EvaluatorResult { + if (ignoreDefaultNamespace) { + xml = xml.replace(/xmlns=".+"/g, (match: string) => { + return match.replace(/xmlns/g, "xmlns:default"); + }); + } + + const nodes = new Array(); + const xdoc: Document = new DOMParser().parseFromString(xml, "text/xml"); + const resolver = (xpath as any).createNSResolver(xdoc); + const xPathResult = xpath.evaluate(query, xdoc, resolver, 0, null); + + const evaluatorResult = new EvaluatorResult(); + evaluatorResult.type = EvaluatorResultType.SCALAR_TYPE; + + switch (xPathResult.resultType) { + case xPathResult.NUMBER_TYPE: + evaluatorResult.result = xPathResult.numberValue; + break; + case xPathResult.STRING_TYPE: + evaluatorResult.result = xPathResult.stringValue; + break; + case xPathResult.BOOLEAN_TYPE: + evaluatorResult.result = xPathResult.booleanValue; + break; + case xPathResult.UNORDERED_NODE_ITERATOR_TYPE: + case xPathResult.ORDERED_NODE_ITERATOR_TYPE: + evaluatorResult.result = xPathResult.booleanValue; + + let node: Node; + + while (node = xPathResult.iterateNext()) { + nodes.push(node); + } + + evaluatorResult.result = nodes; + evaluatorResult.type = EvaluatorResultType.NODE_COLLECTION; + break; + } + + + return evaluatorResult; + } +}