import { XmlFormatter } from "../xml-formatter"; import { XmlFormattingOptions } from "../xml-formatting-options"; import { ClassicXmlFormatter } from "./classic-xml-formatter"; const MagicalStringOfWonders = "~::~MAAAGIC~::~"; /* tslint:disable no-use-before-declare */ export class V2XmlFormatter implements XmlFormatter { formatXml(xml: string, options: XmlFormattingOptions): string { // this replaces all "<" brackets inside of comments to a magical string // so the following minification steps don't mess with comment formatting xml = this._sanitizeComments(xml); // remove whitespace from between tags, except for line breaks xml = xml.replace(/>\s{0,} { return match.replace(/[^\S\r\n]/g, ""); }); // do some light minification to get rid of insignificant whitespace xml = xml.replace(/"\s+(?=[^\s]+=)/g, "\" "); // spaces between attributes xml = xml.replace(/"\s+(?=>)/g, "\""); // spaces between the last attribute and tag close (>) xml = xml.replace(/"\s+(?=\/>)/g, "\" "); // spaces between the last attribute and tag close (/>) xml = xml.replace(/(?!="]\s+[^ <>="]+=(?![^<]*?\]\]>)/g, (match: string) => { // spaces between the node name and the first attribute return match.replace(/\s+/g, " "); }); // the coast is clear - we can drop those "<" brackets back in xml = this._unsanitizeComments(xml); let output = ""; let indentLevel = options.initialIndentLevel; let attributeQuote = ""; let lineBreakSpree = false; let lastWordCharacter: string | undefined; let inMixedContent = false; const locationHistory: Location[] = [Location.Text]; function isLastNonTextLocation(loc: Location): boolean { for (let i = (locationHistory.length - 1); i >= 0; i--) { if (locationHistory[i] !== Location.Text) { return (loc === locationHistory[i]); } } return false; } function isLocation(loc: Location): boolean { return loc === locationHistory[locationHistory.length - 1]; } function refreshMixedContentFlag(): void { inMixedContent = (isLastNonTextLocation(Location.StartTag) || isLastNonTextLocation(Location.EndTag)) && lastWordCharacter !== undefined; } function setLocation(loc: Location): void { if (loc === Location.Text) { lastWordCharacter = undefined; } locationHistory.push(loc); } // NOTE: all "exiting" checks should appear after their associated "entering" checks for (let i = 0; i < xml.length; i++) { const cc = xml[i]; 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 (isLocation(Location.Text) && cc === "<" && nc === "!" && nnc === "[") { if (pc === ">" && ppc !== "/") { output += "<"; } else { output += `${this._getIndent(options, indentLevel)}<`; } setLocation(Location.CData); } // exiting CData else if (isLocation(Location.CData) && cc === "]" && nc === "]" && nnc === ">") { output += "]]>"; i += 2; setLocation(Location.Text); } // entering Comment else if (isLocation(Location.Text) && cc === "<" && nc === "!" && nnc === "-") { output += `${this._getIndent(options, indentLevel)}<`; setLocation(Location.Comment); } // exiting Comment else if (isLocation(Location.Comment) && cc === "-" && nc === "-" && nnc === ">") { output += "-->"; i += 2; setLocation(Location.Text); } // entering SpecialTag else if (isLocation(Location.Text) && cc === "<" && (nc === "!" || nc === "?")) { output += `${this._getIndent(options, indentLevel)}<`; setLocation(Location.SpecialTag); } // exiting SpecialTag else if (isLocation(Location.SpecialTag) && cc === ">") { output += `>`; setLocation(Location.Text); } // entering StartTag.StartTagName else if (isLocation(Location.Text) && cc === "<" && ["/", "!"].indexOf(nc) === -1) { refreshMixedContentFlag(); // if this occurs after another tag, prepend a line break // but do not add one if the previous tag was self-closing (it already adds its own) if (pc === ">" && ppc !== "/" && !inMixedContent) { output += `${options.newLine}${this._getIndent(options, indentLevel)}<`; } else if (!inMixedContent) { // removing trailing non-breaking whitespace here prevents endless indentations (issue #193) output = this._removeTrailingNonBreakingWhitespace(output); output += `${this._getIndent(options, indentLevel)}<`; } else { output += "<"; indentLevel--; } indentLevel++; setLocation(Location.StartTagName); } // exiting StartTag.StartTagName, enter StartTag else if (isLocation(Location.StartTagName) && cc === " ") { output += " "; setLocation(Location.StartTag); } // entering StartTag.Attribute else if (isLocation(Location.StartTag) && [" ", "/", ">"].indexOf(cc) === -1) { if (locationHistory[locationHistory.length - 2] === Location.AttributeValue && ((options.splitXmlnsOnFormat && xml.substr(i, 5).toLowerCase() === "xmlns") || options.splitAttributesOnFormat)) { output += `${options.newLine}${this._getIndent(options, indentLevel)}`; } output += cc; setLocation(Location.Attribute); } // entering StartTag.Attribute.AttributeValue else if (isLocation(Location.Attribute) && (cc === "\"" || cc === "'")) { output += cc; setLocation(Location.AttributeValue); attributeQuote = cc; } // exiting StartTag.Attribute.AttributeValue, entering StartTag else if (isLocation(Location.AttributeValue) && cc === attributeQuote) { output += cc; setLocation(Location.StartTag); attributeQuote = undefined; } // approaching the end of a self-closing tag where there was no whitespace (issue #149) else if ((isLocation(Location.StartTag) || isLocation(Location.StartTagName)) && cc === "/" && pc !== " " && options.enforcePrettySelfClosingTagOnFormat) { output += " /"; } // exiting StartTag or StartTag.StartTagName, entering Text else if ((isLocation(Location.StartTag) || isLocation(Location.StartTagName)) && cc === ">") { // if this was a self-closing tag, we need to decrement the indent level and add a newLine if (pc === "/") { indentLevel--; output += ">"; // only add a newline here if one doesn't already exist (issue #147) if (nc !== "\r" && nc !== "\n") { output += options.newLine; } } else { output += ">"; } // don't go directly from StartTagName to Text; go through StartTag first if (isLocation(Location.StartTagName)) { setLocation(Location.StartTag); } setLocation(Location.Text); } // entering EndTag else if (isLocation(Location.Text) && cc === "<" && nc === "/") { indentLevel--; refreshMixedContentFlag(); // if the end tag immediately follows a line break, just add an indentation // if the end tag immediately follows another end tag or a self-closing tag (issue #185), add a line break and indent // otherwise, this should be treated as a same-line end tag(ex. text) if ((pc === "\n" || lineBreakSpree) && !inMixedContent) { // removing trailing non-breaking whitespace here prevents endless indentations (issue #193) output = this._removeTrailingNonBreakingWhitespace(output); output += `${this._getIndent(options, indentLevel)}<`; lineBreakSpree = false; } else if (isLastNonTextLocation(Location.EndTag) && !inMixedContent) { output += `${options.newLine}${this._getIndent(options, indentLevel)}<`; } else if (pc === ">" && ppc === "/" && !inMixedContent) { output += `${this._getIndent(options, indentLevel)}<`; } else { output += "<"; } setLocation(Location.EndTag); } // exiting EndTag, entering Text else if (isLocation(Location.EndTag) && cc === ">") { output += ">"; setLocation(Location.Text); inMixedContent = false; } // Text else { if (cc === "\n") { lineBreakSpree = true; lastWordCharacter = undefined; } else if (lineBreakSpree && /\S/.test(cc)) { lineBreakSpree = false; } if (/[\w\d]/.test(cc)) { lastWordCharacter = cc; } output += cc; } } return output; } minifyXml(xml: string, options: XmlFormattingOptions): string { return new ClassicXmlFormatter().minifyXml(xml, options); } private _getIndent(options: XmlFormattingOptions, indentLevel: number): string { return ((options.editorOptions.insertSpaces) ? " ".repeat(options.editorOptions.tabSize) : "\t").repeat(indentLevel); } private _removeTrailingNonBreakingWhitespace(text: string): string { return text.replace(/[^\r\n\S]+$/, ""); } private _sanitizeComments(xml: string): string { let output = ""; let inComment = false; for (let i = 0; i < xml.length; i++) { const cc = xml[i]; const nc = xml.charAt(i + 1); const nnc = xml.charAt(i + 2); const pc = xml.charAt(i - 1); if (!inComment && cc === "<" && nc === "!" && nnc === "-") { inComment = true; output += ""; i += 2; } else { output += cc; } } return output; } private _unsanitizeComments(xml: string): string { return xml.replace(new RegExp(MagicalStringOfWonders, "g"), "<"); } } enum Location { Attribute, AttributeValue, CData, Comment, EndTag, SpecialTag, StartTag, StartTagName, Text }