diff --git a/js/src/javascript/tokenizer.js b/js/src/javascript/tokenizer.js index 2b2b73fb0..9965f9c0b 100644 --- a/js/src/javascript/tokenizer.js +++ b/js/src/javascript/tokenizer.js @@ -361,6 +361,32 @@ Tokenizer.prototype._read_string = function(c) { resulting_string = resulting_string.replace(acorn.allLineBreaks, '\n'); + if (c === '`') { + // Escape trailing whitespace characters that appear before a newline + // inside a template literal. Without this, editors that strip trailing + // whitespace (e.g. VS Code) would silently corrupt the template string's + // value. The opening and closing backticks are at positions 0 and last, + // so we only touch the content between them. + // + // Example (issue #2390): a packer may encode \n as a real newline: + // `[ \t\f\r]` → `[ \t\n\f\r]` (semantically identical) + resulting_string = resulting_string.replace( + /([^\S\n]+)(\n)/g, + function(match, trailing_ws, newline) { + // Convert each trailing whitespace character to its escape sequence + // so that editors (e.g. VS Code) that strip trailing whitespace on + // save will not corrupt the template string's value. + var escaped = trailing_ws + .replace(/\t/g, '\\t') + .replace(/\f/g, '\\f') + .replace(/\v/g, '\\v') + .replace(/\u00a0/g, '\\u00a0') + .replace(/ /g, '\\x20'); + return escaped + newline; + } + ); + } + return this._create_token(TOKEN.STRING, resulting_string); } diff --git a/python/jsbeautifier/javascript/tokenizer.py b/python/jsbeautifier/javascript/tokenizer.py index a50aabf33..40ccb1168 100644 --- a/python/jsbeautifier/javascript/tokenizer.py +++ b/python/jsbeautifier/javascript/tokenizer.py @@ -329,6 +329,22 @@ def _read_string(self, c): resulting_string += self._input.next() resulting_string = re.sub(self.acorn.allLineBreaks, "\n", resulting_string) + if c == "`": + + def escape_ws(match): + ws = match.group(1) + ws = ( + ws.replace("\t", "\\t") + .replace("\f", "\\f") + .replace("\v", "\\v") + .replace("\u00a0", "\\u00a0") + .replace(" ", "\\x20") + ) + return ws + match.group(2) + + resulting_string = re.sub( + r"([^\S\n]+)(\n)", escape_ws, resulting_string + ) return self._create_token(TOKEN.STRING, resulting_string) diff --git a/test/data/javascript/tests.js b/test/data/javascript/tests.js index c37efdcb4..338bb2362 100644 --- a/test/data/javascript/tests.js +++ b/test/data/javascript/tests.js @@ -162,6 +162,19 @@ exports.test_data = { { unchanged: 'a = "This is a continuation\\\\\nstring."' }, { unchanged: '`SELECT\n nextval(\\\'${this.options.schema ? `${this.options.schema}.` : \\\'\\\'}"${this.tableName}_${this.autoIncrementField}_seq"\\\'::regclass\n ) nextval;`' }, { + comment: 'Issue #2390 - escape trailing whitespace before real newlines in template literals', + fragment: true, + input: 'let pattern=`[ \t\n\\\\f\\\\r]`', + output: 'let pattern = `[\\\\x20\\\\t\n\\\\f\\\\r]`' + }, { + comment: 'Issue #2390 - tab-only trailing whitespace is escaped', + fragment: true, + input: '`foo\t\nbar`', + output: '`foo\\\\t\nbar`' + }, { + comment: 'Issue #2390 - template literal with newlines but no trailing whitespace should be unchanged', + unchanged: '`line1\nline2\nline3`' + }, { comment: 'Tests for #1030', unchanged: [ 'const composeUrl = (host) => {',