diff --git a/src/compiler/emitter.ts b/src/compiler/emitter.ts index 703bc0365be14..3b9214f82c220 100644 --- a/src/compiler/emitter.ts +++ b/src/compiler/emitter.ts @@ -1152,6 +1152,10 @@ namespace ts { return pipelineEmit(EmitHint.Expression, node); } + function emitJsxAttributeValue(node: StringLiteral | JsxExpression): Node { + return pipelineEmit(isStringLiteral(node) ? EmitHint.JsxAttributeValue : EmitHint.Unspecified, node); + } + function pipelineEmit(emitHint: EmitHint, node: Node) { const savedLastNode = lastNode; const savedLastSubstitution = lastSubstitution; @@ -1219,6 +1223,7 @@ namespace ts { Debug.assert(lastNode === node || lastSubstitution === node); if (hint === EmitHint.SourceFile) return emitSourceFile(cast(node, isSourceFile)); if (hint === EmitHint.IdentifierName) return emitIdentifier(cast(node, isIdentifier)); + if (hint === EmitHint.JsxAttributeValue) return emitLiteral(cast(node, isStringLiteral), /*jsxAttributeEscape*/ true); if (hint === EmitHint.MappedTypeParameter) return emitMappedTypeParameter(cast(node, isTypeParameterDeclaration)); if (hint === EmitHint.EmbeddedStatement) { Debug.assertNode(node, isEmptyStatement); @@ -1232,7 +1237,7 @@ namespace ts { case SyntaxKind.TemplateHead: case SyntaxKind.TemplateMiddle: case SyntaxKind.TemplateTail: - return emitLiteral(node); + return emitLiteral(node, /*jsxAttributeEscape*/ false); case SyntaxKind.UnparsedSource: case SyntaxKind.UnparsedPrepend: @@ -1545,7 +1550,7 @@ namespace ts { case SyntaxKind.StringLiteral: case SyntaxKind.RegularExpressionLiteral: case SyntaxKind.NoSubstitutionTemplateLiteral: - return emitLiteral(node); + return emitLiteral(node, /*jsxAttributeEscape*/ false); // Identifiers case SyntaxKind.Identifier: @@ -1736,7 +1741,7 @@ namespace ts { // SyntaxKind.NumericLiteral // SyntaxKind.BigIntLiteral function emitNumericOrBigIntLiteral(node: NumericLiteral | BigIntLiteral) { - emitLiteral(node); + emitLiteral(node, /*jsxAttributeEscape*/ false); } // SyntaxKind.StringLiteral @@ -1745,8 +1750,8 @@ namespace ts { // SyntaxKind.TemplateHead // SyntaxKind.TemplateMiddle // SyntaxKind.TemplateTail - function emitLiteral(node: LiteralLikeNode) { - const text = getLiteralTextOfNode(node, printerOptions.neverAsciiEscape); + function emitLiteral(node: LiteralLikeNode, jsxAttributeEscape: boolean) { + const text = getLiteralTextOfNode(node, printerOptions.neverAsciiEscape, jsxAttributeEscape); if ((printerOptions.sourceMap || printerOptions.inlineSourceMap) && (node.kind === SyntaxKind.StringLiteral || isTemplateLiteralKind(node.kind))) { writeLiteral(text); @@ -2279,7 +2284,7 @@ namespace ts { expression = skipPartiallyEmittedExpressions(expression); if (isNumericLiteral(expression)) { // check if numeric literal is a decimal literal that was originally written with a dot - const text = getLiteralTextOfNode(expression, /*neverAsciiEscape*/ true); + const text = getLiteralTextOfNode(expression, /*neverAsciiEscape*/ true, /*jsxAttributeEscape*/ false); // If he number will be printed verbatim and it doesn't already contain a dot, add one // if the expression doesn't have any comments that will be emitted. return !expression.numericLiteralFlags && !stringContains(text, tokenToString(SyntaxKind.DotToken)!); @@ -3198,7 +3203,7 @@ namespace ts { function emitJsxAttribute(node: JsxAttribute) { emit(node.name); - emitNodeWithPrefix("=", writePunctuation, node.initializer!, emit); // TODO: GH#18217 + emitNodeWithPrefix("=", writePunctuation, node.initializer, emitJsxAttributeValue); } function emitJsxSpreadAttribute(node: JsxSpreadAttribute) { @@ -3731,7 +3736,7 @@ namespace ts { } } - function emitNodeWithPrefix(prefix: string, prefixWriter: (s: string) => void, node: Node, emit: (node: Node) => void) { + function emitNodeWithPrefix(prefix: string, prefixWriter: (s: string) => void, node: T | undefined, emit: (node: T) => void) { if (node) { prefixWriter(prefix); emit(node); @@ -4288,20 +4293,20 @@ namespace ts { return getSourceTextOfNodeFromSourceFile(currentSourceFile!, node, includeTrivia); } - function getLiteralTextOfNode(node: LiteralLikeNode, neverAsciiEscape: boolean | undefined): string { + function getLiteralTextOfNode(node: LiteralLikeNode, neverAsciiEscape: boolean | undefined, jsxAttributeEscape: boolean): string { if (node.kind === SyntaxKind.StringLiteral && (node).textSourceNode) { const textSourceNode = (node).textSourceNode!; if (isIdentifier(textSourceNode)) { - return neverAsciiEscape || (getEmitFlags(node) & EmitFlags.NoAsciiEscaping) ? - `"${escapeString(getTextOfNode(textSourceNode))}"` : + return jsxAttributeEscape ? `"${escapeJsxAttributeString(getTextOfNode(textSourceNode))}"` : + neverAsciiEscape || (getEmitFlags(node) & EmitFlags.NoAsciiEscaping) ? `"${escapeString(getTextOfNode(textSourceNode))}"` : `"${escapeNonAsciiString(getTextOfNode(textSourceNode))}"`; } else { - return getLiteralTextOfNode(textSourceNode, neverAsciiEscape); + return getLiteralTextOfNode(textSourceNode, neverAsciiEscape, jsxAttributeEscape); } } - return getLiteralText(node, currentSourceFile!, neverAsciiEscape); + return getLiteralText(node, currentSourceFile!, neverAsciiEscape, jsxAttributeEscape); } /** diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 1e0e32bcc45f0..6455b755f1fe6 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -5759,6 +5759,7 @@ namespace ts { MappedTypeParameter, // Emitting a TypeParameterDeclaration inside of a MappedTypeNode Unspecified, // Emitting an otherwise unspecified node EmbeddedStatement, // Emitting an embedded statement + JsxAttributeValue, // Emitting a JSX attribute value } /* @internal */ diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index 879fc1eaeb04e..095a619f78ea5 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -547,7 +547,7 @@ namespace ts { return emitNode && emitNode.flags || 0; } - export function getLiteralText(node: LiteralLikeNode, sourceFile: SourceFile, neverAsciiEscape: boolean | undefined) { + export function getLiteralText(node: LiteralLikeNode, sourceFile: SourceFile, neverAsciiEscape: boolean | undefined, jsxAttributeEscape: boolean) { // If we don't need to downlevel and we can reach the original source text using // the node's parent reference, then simply get the text as it was originally written. if (!nodeIsSynthesized(node) && node.parent && !( @@ -557,24 +557,29 @@ namespace ts { return getSourceTextOfNodeFromSourceFile(sourceFile, node); } - // If a NoSubstitutionTemplateLiteral appears to have a substitution in it, the original text - // had to include a backslash: `not \${a} substitution`. - const escapeText = neverAsciiEscape || (getEmitFlags(node) & EmitFlags.NoAsciiEscaping) ? escapeString : escapeNonAsciiString; - // If we can't reach the original source text, use the canonical form if it's a number, // or a (possibly escaped) quoted form of the original text if it's string-like. switch (node.kind) { - case SyntaxKind.StringLiteral: + case SyntaxKind.StringLiteral: { + const escapeText = jsxAttributeEscape ? escapeJsxAttributeString : + neverAsciiEscape || (getEmitFlags(node) & EmitFlags.NoAsciiEscaping) ? escapeString : + escapeNonAsciiString; if ((node).singleQuote) { return "'" + escapeText(node.text, CharacterCodes.singleQuote) + "'"; } else { return '"' + escapeText(node.text, CharacterCodes.doubleQuote) + '"'; } + } case SyntaxKind.NoSubstitutionTemplateLiteral: case SyntaxKind.TemplateHead: case SyntaxKind.TemplateMiddle: - case SyntaxKind.TemplateTail: + case SyntaxKind.TemplateTail: { + // If a NoSubstitutionTemplateLiteral appears to have a substitution in it, the original text + // had to include a backslash: `not \${a} substitution`. + const escapeText = neverAsciiEscape || (getEmitFlags(node) & EmitFlags.NoAsciiEscaping) ? escapeString : + escapeNonAsciiString; + const rawText = (node).rawText || escapeTemplateSubstitution(escapeText(node.text, CharacterCodes.backtick)); switch (node.kind) { case SyntaxKind.NoSubstitutionTemplateLiteral: @@ -587,6 +592,7 @@ namespace ts { return "}" + rawText + "`"; } break; + } case SyntaxKind.NumericLiteral: case SyntaxKind.BigIntLiteral: case SyntaxKind.RegularExpressionLiteral: @@ -3312,6 +3318,25 @@ namespace ts { "\u0085": "\\u0085" // nextLine }); + function encodeUtf16EscapeSequence(charCode: number): string { + const hexCharCode = charCode.toString(16).toUpperCase(); + const paddedHexCode = ("0000" + hexCharCode).slice(-4); + return "\\u" + paddedHexCode; + } + + function getReplacement(c: string, offset: number, input: string) { + if (c.charCodeAt(0) === CharacterCodes.nullCharacter) { + const lookAhead = input.charCodeAt(offset + c.length); + if (lookAhead >= CharacterCodes._0 && lookAhead <= CharacterCodes._9) { + // If the null character is followed by digits, print as a hex escape to prevent the result from parsing as an octal (which is forbidden in strict mode) + return "\\x00"; + } + // Otherwise, keep printing a literal \0 for the null character + return "\\0"; + } + return escapedCharsMap.get(c) || encodeUtf16EscapeSequence(c.charCodeAt(0)); + } + /** * Based heavily on the abstract 'Quote'/'QuoteJSONString' operation from ECMA-262 (24.3.2.2), * but augmented for a few select characters (e.g. lineSeparator, paragraphSeparator, nextLine) @@ -3325,6 +3350,46 @@ namespace ts { return s.replace(escapedCharsRegExp, getReplacement); } + const nonAsciiCharacters = /[^\u0000-\u007F]/g; + export function escapeNonAsciiString(s: string, quoteChar?: CharacterCodes.doubleQuote | CharacterCodes.singleQuote | CharacterCodes.backtick): string { + s = escapeString(s, quoteChar); + // Replace non-ASCII characters with '\uNNNN' escapes if any exist. + // Otherwise just return the original string. + return nonAsciiCharacters.test(s) ? + s.replace(nonAsciiCharacters, c => encodeUtf16EscapeSequence(c.charCodeAt(0))) : + s; + } + + // This consists of the first 19 unprintable ASCII characters, JSX canonical escapes, lineSeparator, + // paragraphSeparator, and nextLine. The latter three are just desirable to suppress new lines in + // the language service. These characters should be escaped when printing, and if any characters are added, + // the map below must be updated. + const jsxDoubleQuoteEscapedCharsRegExp = /[\"\u0000-\u001f\u2028\u2029\u0085]/g; + const jsxSingleQuoteEscapedCharsRegExp = /[\'\u0000-\u001f\u2028\u2029\u0085]/g; + const jsxEscapedCharsMap = createMapFromTemplate({ + "\"": """, + "\'": "'" + }); + + function encodeJsxCharacterEntity(charCode: number): string { + const hexCharCode = charCode.toString(16).toUpperCase(); + return "&#x" + hexCharCode + ";"; + } + + function getJsxAttributeStringReplacement(c: string) { + if (c.charCodeAt(0) === CharacterCodes.nullCharacter) { + return "�"; + } + return jsxEscapedCharsMap.get(c) || encodeJsxCharacterEntity(c.charCodeAt(0)); + } + + export function escapeJsxAttributeString(s: string, quoteChar?: CharacterCodes.doubleQuote | CharacterCodes.singleQuote) { + const escapedCharsRegExp = + quoteChar === CharacterCodes.singleQuote ? jsxSingleQuoteEscapedCharsRegExp : + jsxDoubleQuoteEscapedCharsRegExp; + return s.replace(escapedCharsRegExp, getJsxAttributeStringReplacement); + } + /** * Strip off existed surrounding single quotes, double quotes, or backticks from a given string * @@ -3344,40 +3409,11 @@ namespace ts { charCode === CharacterCodes.backtick; } - function getReplacement(c: string, offset: number, input: string) { - if (c.charCodeAt(0) === CharacterCodes.nullCharacter) { - const lookAhead = input.charCodeAt(offset + c.length); - if (lookAhead >= CharacterCodes._0 && lookAhead <= CharacterCodes._9) { - // If the null character is followed by digits, print as a hex escape to prevent the result from parsing as an octal (which is forbidden in strict mode) - return "\\x00"; - } - // Otherwise, keep printing a literal \0 for the null character - return "\\0"; - } - return escapedCharsMap.get(c) || get16BitUnicodeEscapeSequence(c.charCodeAt(0)); - } - export function isIntrinsicJsxName(name: __String | string) { const ch = (name as string).charCodeAt(0); return (ch >= CharacterCodes.a && ch <= CharacterCodes.z) || stringContains((name as string), "-"); } - function get16BitUnicodeEscapeSequence(charCode: number): string { - const hexCharCode = charCode.toString(16).toUpperCase(); - const paddedHexCode = ("0000" + hexCharCode).slice(-4); - return "\\u" + paddedHexCode; - } - - const nonAsciiCharacters = /[^\u0000-\u007F]/g; - export function escapeNonAsciiString(s: string, quoteChar?: CharacterCodes.doubleQuote | CharacterCodes.singleQuote | CharacterCodes.backtick): string { - s = escapeString(s, quoteChar); - // Replace non-ASCII characters with '\uNNNN' escapes if any exist. - // Otherwise just return the original string. - return nonAsciiCharacters.test(s) ? - s.replace(nonAsciiCharacters, c => get16BitUnicodeEscapeSequence(c.charCodeAt(0))) : - s; - } - const indentStrings: string[] = ["", " "]; export function getIndentString(level: number) { if (indentStrings[level] === undefined) { diff --git a/src/testRunner/unittests/printer.ts b/src/testRunner/unittests/printer.ts index 39c5503933ad1..d04bf65603c11 100644 --- a/src/testRunner/unittests/printer.ts +++ b/src/testRunner/unittests/printer.ts @@ -11,56 +11,58 @@ namespace ts { describe("printFile", () => { const printsCorrectly = makePrintsCorrectly("printsFileCorrectly"); - // Avoid eagerly creating the sourceFile so that `createSourceFile` doesn't run unless one of these tests is run. - let sourceFile: SourceFile; - before(() => { - sourceFile = createSourceFile("source.ts", ` - interface A { - // comment1 - readonly prop?: T; + describe("comment handling", () => { + // Avoid eagerly creating the sourceFile so that `createSourceFile` doesn't run unless one of these tests is run. + let sourceFile: SourceFile; + before(() => { + sourceFile = createSourceFile("source.ts", ` + interface A { + // comment1 + readonly prop?: T; - // comment2 - method(): void; + // comment2 + method(): void; - // comment3 - new (): A; + // comment3 + new (): A; - // comment4 - (): A; - } + // comment4 + (): A; + } - // comment5 - type B = number | string | object; - type C = A & { x: string; }; // comment6 + // comment5 + type B = number | string | object; + type C = A & { x: string; }; // comment6 - // comment7 - enum E1 { - // comment8 - first - } + // comment7 + enum E1 { + // comment8 + first + } - const enum E2 { - second - } + const enum E2 { + second + } - // comment9 - console.log(1 + 2); + // comment9 + console.log(1 + 2); - // comment10 - function functionWithDefaultArgValue(argument: string = "defaultValue"): void { } - `, ScriptTarget.ES2015); + // comment10 + function functionWithDefaultArgValue(argument: string = "defaultValue"): void { } + `, ScriptTarget.ES2015); + }); + printsCorrectly("default", {}, printer => printer.printFile(sourceFile)); + printsCorrectly("removeComments", { removeComments: true }, printer => printer.printFile(sourceFile)); }); - printsCorrectly("default", {}, printer => printer.printFile(sourceFile)); - printsCorrectly("removeComments", { removeComments: true }, printer => printer.printFile(sourceFile)); - // github #14948 + // https://github.com/microsoft/TypeScript/issues/14948 // eslint-disable-next-line no-template-curly-in-string printsCorrectly("templateLiteral", {}, printer => printer.printFile(createSourceFile("source.ts", "let greeting = `Hi ${name}, how are you?`;", ScriptTarget.ES2017))); - // github #18071 + // https://github.com/microsoft/TypeScript/issues/18071 printsCorrectly("regularExpressionLiteral", {}, printer => printer.printFile(createSourceFile("source.ts", "let regex = /abc/;", ScriptTarget.ES2017))); - // github #22239 + // https://github.com/microsoft/TypeScript/issues/22239 printsCorrectly("importStatementRemoveComments", { removeComments: true }, printer => printer.printFile(createSourceFile("source.ts", "import {foo} from 'foo';", ScriptTarget.ESNext))); printsCorrectly("classHeritageClauses", {}, printer => printer.printFile(createSourceFile( "source.ts", @@ -68,16 +70,28 @@ namespace ts { ScriptTarget.ES2017 ))); - // github #35093 + // https://github.com/microsoft/TypeScript/issues/35093 printsCorrectly("definiteAssignmentAssertions", {}, printer => printer.printFile(createSourceFile( "source.ts", `class A { prop!: string; } - + let x!: string;`, ScriptTarget.ES2017 ))); + + // https://github.com/microsoft/TypeScript/issues/35054 + printsCorrectly("jsx attribute escaping", {}, printer => { + debugger; + return printer.printFile(createSourceFile( + "source.ts", + String.raw``, + ScriptTarget.ESNext, + /*setParentNodes*/ undefined, + ScriptKind.TSX + )); + }); }); describe("printBundle", () => { diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 831fa82701400..bffd2556fc69b 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -2939,7 +2939,8 @@ declare namespace ts { IdentifierName = 2, MappedTypeParameter = 3, Unspecified = 4, - EmbeddedStatement = 5 + EmbeddedStatement = 5, + JsxAttributeValue = 6 } export interface TransformationContext { /** Gets the compiler options supplied to the transformer. */ diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index 3f1c97df0fed0..75e4a47493992 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -2939,7 +2939,8 @@ declare namespace ts { IdentifierName = 2, MappedTypeParameter = 3, Unspecified = 4, - EmbeddedStatement = 5 + EmbeddedStatement = 5, + JsxAttributeValue = 6 } export interface TransformationContext { /** Gets the compiler options supplied to the transformer. */ diff --git a/tests/baselines/reference/printerApi/printsFileCorrectly.jsx attribute escaping.js b/tests/baselines/reference/printerApi/printsFileCorrectly.jsx attribute escaping.js new file mode 100644 index 0000000000000..a13befefab4a9 --- /dev/null +++ b/tests/baselines/reference/printerApi/printsFileCorrectly.jsx attribute escaping.js @@ -0,0 +1 @@ +;