diff --git a/README.md b/README.md index f7e86e7..150d5b0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ * **`xmlTools.enableXmlTreeView`:** Enables the XML Tree View for XML documents. * **`xmlTools.enableXmlTreeViewMetadata`:** Enables attribute and child element counts in the XML Document view. * **`xmlTools.enableXmlTreeViewCursorSync`:** Enables auto-reveal of elements in the XML Document view when a start tag is clicked in the editor. +* **`xmlTools.enforcePrettySelfClosingTagOnFormat`:** Ensures a space is added before the forward slash at the end of a self-closing tag. * **`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.json b/package.json index 11dd062..c4ec2b2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "xml", "displayName": "XML Tools", "description": "XML Formatting, XQuery, and XPath Tools for Visual Studio Code", - "version": "2.1.1", + "version": "2.2.0", "preview": false, "publisher": "DotJoshJohnson", "author": { @@ -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" @@ -78,10 +82,16 @@ }, "xmlTools.enableXmlTreeViewCursorSync": { "type": "boolean", - "default": true, + "default": false, "description": "Enables auto-reveal of elements in the XML Document view when a start tag is clicked in the editor.", "scope": "window" }, + "xmlTools.enforcePrettySelfClosingTagOnFormat": { + "type": "boolean", + "default": false, + "description": "Enforces a space before the forward slash at the end of a self-closing XML tag.", + "scope": "resource" + }, "xmlTools.ignoreDefaultNamespace": { "type": "boolean", "default": true, @@ -114,6 +124,10 @@ }, "xmlTools.xmlFormatterImplementation": { "type": "string", + "enum": [ + "classic", + "v2" + ], "default": "v2", "description": "Supported XML Formatters: classic", "scope": "window" @@ -195,6 +209,10 @@ "command": "xmlTools.executeXQuery", "when": "editorLangId == xquery" }, + { + "command": "xmlTools.getCurrentXPath", + "when": "editorLangId == xml" + }, { "command": "xmlTools.minifyXml", "when": "editorLangId == xml" diff --git a/src/common/configuration.ts b/src/common/configuration.ts index 0a3ef1c..b1461d0 100644 --- a/src/common/configuration.ts +++ b/src/common/configuration.ts @@ -43,6 +43,10 @@ export class Configuration { return this._getForWindow("xqueryExecutionInputSearchPattern"); } + static enforcePrettySelfClosingTagOnFormat(resource: Uri): boolean { + return this._getForResource("enforcePrettySelfClosingTagOnFormat", resource); + } + static removeCommentsOnMinify(resource: Uri): boolean { return this._getForResource("removeCommentsOnMinify", resource); } 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/formatting/formatters/v2-xml-formatter.ts b/src/formatting/formatters/v2-xml-formatter.ts index daa7994..c67b0c9 100644 --- a/src/formatting/formatters/v2-xml-formatter.ts +++ b/src/formatting/formatters/v2-xml-formatter.ts @@ -39,6 +39,7 @@ export class V2XmlFormatter implements XmlFormatter { const nc = xml.charAt(i + 1); const nnc = xml.charAt(i + 2); const pc = xml.charAt(i - 1); + const ppc = xml.charAt(i - 2); // entering CData if (location === Location.Text && cc === "<" && nc === "!" && nnc === "[") { @@ -86,7 +87,8 @@ export class V2XmlFormatter implements XmlFormatter { // entering StartTag.StartTagName else if (location === Location.Text && cc === "<" && ["/", "!"].indexOf(nc) === -1) { // if this occurs after another tag, prepend a line break - if (pc === ">") { + // but do not add one if the previous tag was self-closing (it already adds its own) + if (pc === ">" && ppc !== "/") { output += `${options.newLine}${this._getIndent(options, indentLevel)}<`; } @@ -120,19 +122,27 @@ export class V2XmlFormatter implements XmlFormatter { } // entering StartTag.Attribute.AttributeValue - else if (location === Location.Attribute && cc === "\"") { - output += "\""; + else if (location === Location.Attribute && (cc === "\"" || cc === "'")) { + output += cc; lastNonTextLocation = location; location = Location.AttributeValue; } // exiting StartTag.Attribute.AttributeValue, entering StartTag - else if (location === Location.AttributeValue && cc === "\"") { - output += "\""; + else if (location === Location.AttributeValue && (cc === "\"" || cc === "'")) { + output += cc; lastNonTextLocation = location; location = Location.StartTag; } + // approaching the end of a self-closing tag where there was no whitespace (issue #149) + else if ((location === Location.StartTag || location === Location.StartTagName) + && cc === "/" + && pc !== " " + && options.enforcePrettySelfClosingTagOnFormat) { + output += " /"; + } + // exiting StartTag or StartTag.StartTagName, entering Text else if ((location === Location.StartTag || location === Location.StartTagName) && cc === ">") { // if this was a self-closing tag, we need to decrement the indent level and add a newLine diff --git a/src/formatting/xml-formatting-options.ts b/src/formatting/xml-formatting-options.ts index bcfdffd..06782a1 100644 --- a/src/formatting/xml-formatting-options.ts +++ b/src/formatting/xml-formatting-options.ts @@ -5,6 +5,7 @@ import * as constants from "../constants"; export interface XmlFormattingOptions { editorOptions: FormattingOptions; + enforcePrettySelfClosingTagOnFormat: boolean; newLine: string; removeCommentsOnMinify: boolean; splitAttributesOnFormat: boolean; @@ -15,6 +16,7 @@ export class XmlFormattingOptionsFactory { static getXmlFormattingOptions(formattingOptions: FormattingOptions, document: TextDocument): XmlFormattingOptions { return { editorOptions: formattingOptions, + enforcePrettySelfClosingTagOnFormat: Configuration.enforcePrettySelfClosingTagOnFormat(document.uri), newLine: (document.eol === EndOfLine.CRLF) ? "\r\n" : "\n", removeCommentsOnMinify: Configuration.removeCommentsOnMinify(document.uri), splitAttributesOnFormat: Configuration.splitAttributesOnFormat(document.uri), diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index ce6f035..50adb2b 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -17,6 +17,7 @@ describe("V2XmlFormatter", () => { insertSpaces: true, tabSize: 4 }, + enforcePrettySelfClosingTagOnFormat: false, newLine: "\r\n", removeCommentsOnMinify: false, splitAttributesOnFormat: false, @@ -51,6 +52,22 @@ describe("V2XmlFormatter", () => { testFormatter(xmlFormatter, options, "maintain-comment-formatting"); }); + it("should handle single-quotes in attributes", () => { + testFormatter(xmlFormatter, options, "single-quotes"); + }); + + it("should not add extra line breaks before start tags", () => { + testFormatter(xmlFormatter, options, "issue-178"); + }); + + it("should allow users to enforce space before self-closing tag slash", () => { + options.enforcePrettySelfClosingTagOnFormat = true; + + testFormatter(xmlFormatter, options, "issue-149"); + + options.enforcePrettySelfClosingTagOnFormat = false; + }); + }); }); diff --git a/src/test/test-data/issue-149.formatted.xml b/src/test/test-data/issue-149.formatted.xml new file mode 100644 index 0000000..fc5d4e0 --- /dev/null +++ b/src/test/test-data/issue-149.formatted.xml @@ -0,0 +1,9 @@ + + + One + + Three + + Five + + \ No newline at end of file diff --git a/src/test/test-data/issue-149.unformatted.xml b/src/test/test-data/issue-149.unformatted.xml new file mode 100644 index 0000000..6e8113b --- /dev/null +++ b/src/test/test-data/issue-149.unformatted.xml @@ -0,0 +1 @@ +OneThreeFive \ No newline at end of file diff --git a/src/test/test-data/issue-178.formatted.xml b/src/test/test-data/issue-178.formatted.xml new file mode 100644 index 0000000..abe2b8b --- /dev/null +++ b/src/test/test-data/issue-178.formatted.xml @@ -0,0 +1,9 @@ + + + One + + Three + + Five + + \ No newline at end of file diff --git a/src/test/test-data/issue-178.unformatted.xml b/src/test/test-data/issue-178.unformatted.xml new file mode 100644 index 0000000..6e8113b --- /dev/null +++ b/src/test/test-data/issue-178.unformatted.xml @@ -0,0 +1 @@ +OneThreeFive \ No newline at end of file diff --git a/src/test/test-data/single-quotes.formatted.xml b/src/test/test-data/single-quotes.formatted.xml new file mode 100644 index 0000000..550877b --- /dev/null +++ b/src/test/test-data/single-quotes.formatted.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/test/test-data/single-quotes.unformatted.xml b/src/test/test-data/single-quotes.unformatted.xml new file mode 100644 index 0000000..550877b --- /dev/null +++ b/src/test/test-data/single-quotes.unformatted.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file 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}`; + } + } + +} diff --git a/src/xquery-execution/commands/executeXQuery.ts b/src/xquery-execution/commands/executeXQuery.ts index 90c0525..a74ed0e 100644 --- a/src/xquery-execution/commands/executeXQuery.ts +++ b/src/xquery-execution/commands/executeXQuery.ts @@ -101,7 +101,7 @@ export async function executeXQuery(editor: TextEditor, edit: TextEditorEdit): P return value .replace("$(script)", editor.document.uri.fsPath) .replace("$(input)", inputFile.fsPath) - .replace("$(project)", workspace.rootPath); + .replace("$(project)", (workspace.workspaceFolders) ? workspace.workspaceFolders[0].uri.fsPath : ""); }); try {