diff --git a/src/language-handlebars/clean.js b/src/language-handlebars/clean.js index d979246fc..04d8caa48 100644 --- a/src/language-handlebars/clean.js +++ b/src/language-handlebars/clean.js @@ -1,3 +1,5 @@ +import htmlWhitespaceUtils from "../utils/html-whitespace-utils.js"; + function clean(ast, newNode /*, parent*/) { // (Glimmer/HTML) ignore TextNode if (ast.type === "TextNode") { @@ -5,7 +7,7 @@ function clean(ast, newNode /*, parent*/) { if (!trimmed) { return null; } - newNode.chars = trimmed.replaceAll(/[\t\n\f\r ]+/g, " "); + newNode.chars = htmlWhitespaceUtils.split(trimmed).join(" "); } // `class` is reformatted diff --git a/src/language-handlebars/printer-glimmer.js b/src/language-handlebars/printer-glimmer.js index 41c7d0ad6..14ce5bbcb 100644 --- a/src/language-handlebars/printer-glimmer.js +++ b/src/language-handlebars/printer-glimmer.js @@ -13,6 +13,7 @@ import { replaceEndOfLine } from "../document/utils.js"; import getPreferredQuote from "../utils/get-preferred-quote.js"; import isNonEmptyArray from "../utils/is-non-empty-array.js"; import UnexpectedNodeError from "../utils/unexpected-node-error.js"; +import htmlWhitespaceUtils from "../utils/html-whitespace-utils.js"; import { locStart, locEnd } from "./loc.js"; import clean from "./clean.js"; import { hasPrettierIgnore, isVoidElement, isWhitespaceNode } from "./utils.js"; @@ -199,15 +200,10 @@ function print(path, options, print) { return replaceEndOfLine(text); } - const whitespacesOnlyRE = /^[\t\n\f\r ]*$/; - const isWhitespaceOnly = whitespacesOnlyRE.test(text); + const isWhitespaceOnly = htmlWhitespaceUtils.isWhitespaceOnly(text); const { isFirst, isLast } = path; if (options.htmlWhitespaceSensitivity !== "ignore") { - // https://infra.spec.whatwg.org/#ascii-whitespace - const leadingWhitespacesRE = /^[\t\n\f\r ]*/; - const trailingWhitespacesRE = /[\t\n\f\r ]*$/; - // let's remove the file's final newline // https://github.com/ember-cli/ember-new-output/blob/1a04c67ddd02ccb35e0ff41bb5cbce34b31173ef/.editorconfig#L16 const shouldTrimTrailingNewlines = @@ -234,27 +230,29 @@ function print(path, options, print) { return breaks; } - const [lead] = text.match(leadingWhitespacesRE); - const [tail] = text.match(trailingWhitespacesRE); + const leadingWhitespace = + htmlWhitespaceUtils.getLeadingWhitespace(text); let leadBreaks = []; - if (lead) { + if (leadingWhitespace) { leadBreaks = [line]; - const leadingNewlines = countNewLines(lead); + const leadingNewlines = countNewLines(leadingWhitespace); if (leadingNewlines) { leadBreaks = generateHardlines(leadingNewlines); } - text = text.replace(leadingWhitespacesRE, ""); + text = text.slice(leadingWhitespace.length); } + const tailingWhitespace = + htmlWhitespaceUtils.getTrailingWhitespace(text); let trailBreaks = []; - if (tail) { + if (tailingWhitespace) { if (!shouldTrimTrailingNewlines) { trailBreaks = [line]; - const trailingNewlines = countNewLines(tail); + const trailingNewlines = countNewLines(tailingWhitespace); if (trailingNewlines) { trailBreaks = generateHardlines(trailingNewlines); } @@ -264,7 +262,7 @@ function print(path, options, print) { } } - text = text.replace(trailingWhitespacesRE, ""); + text = text.slice(0, -tailingWhitespace.length); } return [...leadBreaks, fill(getTextValueParts(text)), ...trailBreaks]; @@ -334,9 +332,13 @@ function print(path, options, print) { trailingSpace = ""; } - text = text - .replaceAll(/^[\t\n\f\r ]+/g, leadingSpace) - .replace(/[\t\n\f\r ]+$/, trailingSpace); + if (htmlWhitespaceUtils.hasLeadingWhitespace(text)) { + text = leadingSpace + htmlWhitespaceUtils.trimStart(text); + } + + if (htmlWhitespaceUtils.hasTrailingWhitespace(text)) { + text = htmlWhitespaceUtils.trimEnd(text) + trailingSpace; + } return [ ...generateHardlines(leadingLineBreaksCount), @@ -643,11 +645,7 @@ function printInverse(path, print, options) { /* TextNode print helpers */ function getTextValueParts(value) { - return join(line, splitByHtmlWhitespace(value)); -} - -function splitByHtmlWhitespace(string) { - return string.split(/[\t\n\f\r ]+/); + return join(line, htmlWhitespaceUtils.split(value)); } function getCurrentAttributeName(path) { diff --git a/src/language-html/print-preprocess.js b/src/language-html/print-preprocess.js index 7c66b1dc9..9f1259c0d 100644 --- a/src/language-html/print-preprocess.js +++ b/src/language-html/print-preprocess.js @@ -1,8 +1,7 @@ import { ParseSourceSpan } from "angular-html-parser/lib/compiler/src/parse_util.js"; +import htmlWhitespaceUtils from "../utils/html-whitespace-utils.js"; import { - htmlTrim, getLeadingAndTrailingHtmlWhitespace, - hasHtmlWhitespace, canHaveInterpolation, getNodeCssStyleDisplay, isDanglingSpaceSensitiveNode, @@ -145,7 +144,7 @@ function mergeSimpleElementIntoText(ast /*, options */) { node.attrs.length === 0 && node.children.length === 1 && node.firstChild.type === "text" && - !hasHtmlWhitespace(node.children[0].value) && + !htmlWhitespaceUtils.hasWhitespaceCharacter(node.children[0].value) && !node.firstChild.hasLeadingSpaces && !node.firstChild.hasTrailingSpaces && node.isLeadingSpaceSensitive && @@ -264,7 +263,7 @@ function extractWhitespaces(ast /*, options*/) { node.children.length === 0 || (node.children.length === 1 && node.children[0].type === "text" && - htmlTrim(node.children[0].value).length === 0) + htmlWhitespaceUtils.trim(node.children[0].value).length === 0) ) { node.hasDanglingSpaces = node.children.length > 0; node.children = []; diff --git a/src/language-html/utils/index.js b/src/language-html/utils/index.js index 3d57ceb8a..c23f9e356 100644 --- a/src/language-html/utils/index.js +++ b/src/language-html/utils/index.js @@ -12,30 +12,30 @@ import { CSS_WHITE_SPACE_TAGS, CSS_WHITE_SPACE_DEFAULT, } from "../constants.evaluate.js"; +import htmlWhitespaceUtils from "../../utils/html-whitespace-utils.js"; import isUnknownNamespace from "./is-unknown-namespace.js"; -// https://infra.spec.whatwg.org/#ascii-whitespace -const HTML_WHITESPACE = new Set(["\t", "\n", "\f", "\r", " "]); -const htmlTrimStart = (string) => string.replace(/^[\t\n\f\r ]+/, ""); -const htmlTrimEnd = (string) => string.replace(/[\t\n\f\r ]+$/, ""); -const htmlTrim = (string) => htmlTrimStart(htmlTrimEnd(string)); const htmlTrimLeadingBlankLines = (string) => string.replaceAll(/^[\t\f\r ]*\n/g, ""); const htmlTrimPreserveIndentation = (string) => - htmlTrimLeadingBlankLines(htmlTrimEnd(string)); -const splitByHtmlWhitespace = (string) => string.split(/[\t\n\f\r ]+/); -const getLeadingHtmlWhitespace = (string) => string.match(/^[\t\n\f\r ]*/)[0]; + htmlTrimLeadingBlankLines(htmlWhitespaceUtils.trimEnd(string)); const getLeadingAndTrailingHtmlWhitespace = (string) => { - const [, leadingWhitespace, text, trailingWhitespace] = string.match( - /^([\t\n\f\r ]*)(.*?)([\t\n\f\r ]*)$/s - ); + let text = string; + const leadingWhitespace = htmlWhitespaceUtils.getLeadingWhitespace(text); + if (leadingWhitespace) { + text = text.slice(leadingWhitespace.length); + } + const trailingWhitespace = htmlWhitespaceUtils.getTrailingWhitespace(text); + if (trailingWhitespace) { + text = text.slice(0, -trailingWhitespace.length); + } + return { leadingWhitespace, trailingWhitespace, text, }; }; -const hasHtmlWhitespace = (string) => /[\t\n\f\r ]/.test(string); function shouldPreserveContent(node, options) { // unterminated node in ie conditional comment @@ -513,12 +513,11 @@ function getMinIndentation(text) { continue; } - if (!HTML_WHITESPACE.has(lineText[0])) { + const indentation = htmlWhitespaceUtils.getLeadingWhitespaceCount(lineText); + if (indentation === 0) { return 0; } - const indentation = getLeadingHtmlWhitespace(lineText).length; - if (lineText.length === indentation) { continue; } @@ -612,7 +611,7 @@ function getTextValueParts(node, value = node.value) { dedentString(htmlTrimPreserveIndentation(value)), hardline ) - : join(line, splitByHtmlWhitespace(value)); + : join(line, htmlWhitespaceUtils.split(value)); } function isVueScriptTag(node, options) { @@ -620,9 +619,7 @@ function isVueScriptTag(node, options) { } export { - htmlTrim, htmlTrimPreserveIndentation, - hasHtmlWhitespace, getLeadingAndTrailingHtmlWhitespace, canHaveInterpolation, countChars, @@ -655,4 +652,5 @@ export { shouldPreserveContent, unescapeQuoteEntities, getTextValueParts, + htmlWhitespaceUtils, }; diff --git a/src/language-js/print/jsx.js b/src/language-js/print/jsx.js index 9a696dfa8..de71874d7 100644 --- a/src/language-js/print/jsx.js +++ b/src/language-js/print/jsx.js @@ -17,6 +17,7 @@ import { import { willBreak, replaceEndOfLine } from "../../document/utils.js"; import UnexpectedNodeError from "../../utils/unexpected-node-error.js"; import getPreferredQuote from "../../utils/get-preferred-quote.js"; +import WhitespaceUtils from "../../utils/whitespace-utils.js"; import { isJsxElement, rawText, @@ -32,6 +33,16 @@ import { import pathNeedsParens from "../needs-parens.js"; import { willPrintOwnComments } from "../comments/printer-methods.js"; +/* +Only the following are treated as whitespace inside JSX. + +- U+0020 SPACE +- U+000A LF +- U+000D CR +- U+0009 TAB +*/ +const jsxWhitespaceUtils = new WhitespaceUtils(" \n\r\t"); + const isEmptyStringOrAnyLine = (doc) => doc === "" || doc === line || doc === hardline || doc === softline; @@ -276,7 +287,10 @@ function printJsxChildren( // Contains a non-whitespace character if (isMeaningfulJsxText(node)) { - const words = text.split(matchJsxWhitespaceRegex); + const words = jsxWhitespaceUtils.split( + text, + /* captureWhitespace */ true + ); // Starts with whitespace if (words[0] === "") { @@ -356,9 +370,8 @@ function printJsxChildren( const directlyFollowedByMeaningfulText = next && isMeaningfulJsxText(next); if (directlyFollowedByMeaningfulText) { - const firstWord = trimJsxWhitespace(rawText(next)).split( - matchJsxWhitespaceRegex - )[0]; + const trimmed = jsxWhitespaceUtils.trim(rawText(next)); + const [firstWord] = jsxWhitespaceUtils.split(trimmed); parts.push( separatorNoWhitespace(isFacebookTranslationTag, firstWord, node, next) ); @@ -751,25 +764,6 @@ function printJsx(path, options, print) { } } -// Only space, newline, carriage return, and tab are treated as whitespace -// inside JSX. -const jsxWhitespaceChars = " \n\r\t"; -const matchJsxWhitespaceRegex = new RegExp("([" + jsxWhitespaceChars + "]+)"); -const containsNonJsxWhitespaceRegex = new RegExp( - "[^" + jsxWhitespaceChars + "]" -); -const trimJsxWhitespace = (text) => - text.replace( - new RegExp( - "^" + - matchJsxWhitespaceRegex.source + - "|" + - matchJsxWhitespaceRegex.source + - "$" - ), - "" - ); - /** * @param {JSXElement} node * @returns {boolean} @@ -797,7 +791,7 @@ function isEmptyJsxElement(node) { function isMeaningfulJsxText(node) { return ( node.type === "JSXText" && - (containsNonJsxWhitespaceRegex.test(rawText(node)) || + (jsxWhitespaceUtils.hasNonWhitespaceCharacter(rawText(node)) || !/\n/.test(rawText(node))) ); } diff --git a/src/utils/html-whitespace-utils.js b/src/utils/html-whitespace-utils.js new file mode 100644 index 000000000..d87e832fb --- /dev/null +++ b/src/utils/html-whitespace-utils.js @@ -0,0 +1,7 @@ +import WhitespaceUtils from "./whitespace-utils.js"; + +// https://infra.spec.whatwg.org/#ascii-whitespace +const HTML_WHITESPACE_CHARACTERS = ["\t", "\n", "\f", "\r", " "]; +const htmlWhitespaceUtils = new WhitespaceUtils(HTML_WHITESPACE_CHARACTERS); + +export default htmlWhitespaceUtils; diff --git a/src/utils/whitespace-utils.js b/src/utils/whitespace-utils.js new file mode 100644 index 000000000..781467326 --- /dev/null +++ b/src/utils/whitespace-utils.js @@ -0,0 +1,116 @@ +import escapeStringRegexp from "escape-string-regexp"; + +class WhitespaceUtils { + #whitespaceCharacters; + + constructor(whitespaceCharacters) { + this.#whitespaceCharacters = new Set(whitespaceCharacters); + + if ( + process.env.NODE_ENV !== "production" && + (this.#whitespaceCharacters.size === 0 || + Array.prototype.some.call( + whitespaceCharacters, + (character) => !/^\s$/.test(character) + )) + ) { + throw new TypeError( + `Invalid characters: ${JSON.stringify(whitespaceCharacters)}` + ); + } + } + + getLeadingWhitespaceCount(text) { + const whitespaceCharacters = this.#whitespaceCharacters; + let count = 0; + + for ( + let index = 0; + index < text.length && whitespaceCharacters.has(text.charAt(index)); + index++ + ) { + count++; + } + + return count; + } + + getTrailingWhitespaceCount(text) { + const whitespaceCharacters = this.#whitespaceCharacters; + let count = 0; + + for ( + let index = text.length - 1; + index >= 0 && whitespaceCharacters.has(text.charAt(index)); + index-- + ) { + count++; + } + + return count; + } + + getLeadingWhitespace(text) { + const count = this.getLeadingWhitespaceCount(text); + return text.slice(0, count); + } + + getTrailingWhitespace(text) { + const count = this.getTrailingWhitespaceCount(text); + return text.slice(text.length - count); + } + + hasLeadingWhitespace(text) { + return this.#whitespaceCharacters.has(text.charAt(0)); + } + + hasTrailingWhitespace(text) { + return this.#whitespaceCharacters.has(text.at(-1)); + } + + trimStart(text) { + const count = this.getLeadingWhitespaceCount(text); + return text.slice(count); + } + + trimEnd(text) { + const count = this.getTrailingWhitespaceCount(text); + return text.slice(0, text.length - count); + } + + trim(text) { + return this.trimEnd(this.trimStart(text)); + } + + split(text, captureWhitespace = false) { + const pattern = `[${escapeStringRegexp( + [...this.#whitespaceCharacters].join("") + )}]+`; + const regexp = new RegExp(captureWhitespace ? `(${pattern})` : pattern); + return text.split(regexp); + } + + hasWhitespaceCharacter(text) { + const whitespaceCharacters = this.#whitespaceCharacters; + return Array.prototype.some.call(text, (character) => + whitespaceCharacters.has(character) + ); + } + + hasNonWhitespaceCharacter(text) { + const whitespaceCharacters = this.#whitespaceCharacters; + return Array.prototype.some.call( + text, + (character) => !whitespaceCharacters.has(character) + ); + } + + isWhitespaceOnly(text) { + const whitespaceCharacters = this.#whitespaceCharacters; + return Array.prototype.every.call(text, (character) => + whitespaceCharacters.has(character) + ); + } +} + +export default WhitespaceUtils; diff --git a/tests/unit/whitespace-utils.js b/tests/unit/whitespace-utils.js new file mode 100644 index 000000000..7ed383bbe --- /dev/null +++ b/tests/unit/whitespace-utils.js @@ -0,0 +1,115 @@ +import WhitespaceUtils from "../../src/utils/whitespace-utils.js"; + +it("constructor", () => { + expect(() => new WhitespaceUtils()).toThrow(TypeError); + expect(() => new WhitespaceUtils(1)).toThrow(TypeError); + expect(() => new WhitespaceUtils("")).toThrow(TypeError); + expect(() => new WhitespaceUtils([])).toThrow(TypeError); + expect(() => new WhitespaceUtils([""])).toThrow(TypeError); + expect(() => new WhitespaceUtils(["a"])).toThrow(TypeError); + expect(() => new WhitespaceUtils("a")).toThrow(TypeError); + expect(() => new WhitespaceUtils(["\r\n"])).toThrow(TypeError); + + expect(new WhitespaceUtils("\r\n")).toBeInstanceOf(WhitespaceUtils); + expect(new WhitespaceUtils(["\r", "\n"])).toBeInstanceOf(WhitespaceUtils); +}); + +const utils = new WhitespaceUtils(" "); +describe("trimStart", () => { + for (const [string, expected] of [ + ["", ""], + [" ", ""], + [" ".repeat(3), ""], + ["a", "a"], + ["a" + " ".repeat(3), "a "], + [" ".repeat(3) + "a", "a"], + [" ".repeat(3) + "a" + " ".repeat(3), "a "], + ]) { + it(JSON.stringify(string), () => { + expect(utils.trimStart(string)).toBe(expected); + }); + } +}); + +describe("trimEnd", () => { + for (const [string, expected] of [ + ["", ""], + [" ", ""], + [" ".repeat(3), ""], + ["a", "a"], + ["a" + " ".repeat(3), "a"], + [" ".repeat(3) + "a", " a"], + [" ".repeat(3) + "a" + " ".repeat(3), " a"], + ]) { + it(JSON.stringify(string), () => { + expect(utils.trimEnd(string)).toBe(expected); + }); + } +}); + +describe("trim", () => { + for (const [string, expected] of [ + ["", ""], + [" ", ""], + [" ".repeat(3), ""], + ["a", "a"], + ["a" + " ".repeat(3), "a"], + [" ".repeat(3) + "a", "a"], + [" ".repeat(3) + "a" + " ".repeat(3), "a"], + ]) { + it(JSON.stringify(string), () => { + expect(utils.trim(string)).toBe(expected); + }); + } +}); + +describe("getLeadingWhitespaceCount", () => { + for (const [string, expected] of [ + ["", 0], + [" ", 1], + [" ".repeat(3), 3], + ["a", 0], + ["a" + " ".repeat(3), 0], + [" ".repeat(3) + "a", 3], + [" ".repeat(3) + "a" + " ".repeat(3), 3], + ]) { + it(JSON.stringify(string), () => { + expect(utils.getLeadingWhitespaceCount(string)).toBe(expected); + }); + } +}); + +describe("getTrailingWhitespaceCount", () => { + for (const [string, expected] of [ + ["", 0], + [" ", 1], + [" ".repeat(3), 3], + ["a", 0], + ["a" + " ".repeat(3), 3], + [" ".repeat(3) + "a", 0], + [" ".repeat(3) + "a" + " ".repeat(3), 3], + ]) { + it(JSON.stringify(string), () => { + expect(utils.getTrailingWhitespaceCount(string)).toBe(expected); + }); + } +}); + +describe("split", () => { + for (const string of [ + "", + " ", + " ".repeat(3), + " a", + " ".repeat(3) + "a", + "a ", + "a" + " ".repeat(3), + " a ", + " ".repeat(3) + "a" + " ".repeat(3), + " a a a ", + ]) { + it(JSON.stringify(string), () => { + expect(utils.split(string)).toEqual(string.split(/ +/)); + }); + } +});