diff --git a/src/comments.ts b/src/comments.ts index ac6bc84f..49e6f4ed 100644 --- a/src/comments.ts +++ b/src/comments.ts @@ -70,10 +70,13 @@ export function canAttachComment(node: SyntaxNode) { } switch (node.type) { case SyntaxType.EnumBodyDeclarations: + case SyntaxType.EscapeSequence: case SyntaxType.FormalParameters: case SyntaxType.Modifier: + case SyntaxType.MultilineStringFragment: case SyntaxType.ParenthesizedExpression: case SyntaxType.Program: + case SyntaxType.StringFragment: case SyntaxType.Visibility: return false; default: diff --git a/src/printer.ts b/src/printer.ts index 5c99fdab..8a8725f3 100644 --- a/src/printer.ts +++ b/src/printer.ts @@ -9,6 +9,8 @@ import { } from "./comments.ts"; import { SyntaxType, type CommentNode, type SyntaxNode } from "./node-types.ts"; import { + embedTextBlock, + hasType, printComment, printValue, type NamedNodePath @@ -21,6 +23,11 @@ export default { ? printerForNodeType(path.node.type)(path, print, options, args) : printValue(path); }, + embed(path) { + return hasType(path, SyntaxType.StringLiteral) + ? embedTextBlock(path) + : null; + }, hasPrettierIgnore(path) { return ( path.node.comments?.some(isPrettierIgnore) === true || @@ -42,6 +49,9 @@ export default { ownLine: handleLineComment, endOfLine: handleLineComment, remaining: handleRemainingComment + }, + getVisitorKeys() { + return ["namedChildren"]; } } satisfies Printer; diff --git a/src/printers/helpers.ts b/src/printers/helpers.ts index 6d4d2094..bbe9c792 100644 --- a/src/printers/helpers.ts +++ b/src/printers/helpers.ts @@ -1,5 +1,5 @@ -import type { AstPath, Doc, ParserOptions } from "prettier"; -import { builders } from "prettier/doc"; +import type { AstPath, Doc, Options, ParserOptions } from "prettier"; +import { builders, utils } from "prettier/doc"; import { SyntaxType, type CommentNode, @@ -9,6 +9,7 @@ import { } from "../node-types.ts"; const { group, hardline, ifBreak, indent, join, line, softline } = builders; +const { mapDoc } = utils; export function hasType( path: AstPath, @@ -315,12 +316,117 @@ export function printVariableDeclaration( return declaration; } -export function findBaseIndent(lines: string[]) { - return lines.length - ? Math.min( - ...lines.map(line => line.search(/\S/)).filter(indent => indent >= 0) - ) - : 0; +export function printTextBlock( + path: NamedNodePath, + contents: Doc +) { + const parts = ['"""', hardline, contents, '"""']; + const parentType = (path.parent as NamedNode | null)?.type; + const grandparentType = (path.grandparent as NamedNode | null)?.type; + return parentType === SyntaxType.AssignmentExpression || + parentType === SyntaxType.VariableDeclarator || + (path.node.fieldName === "object" && + (grandparentType === SyntaxType.AssignmentExpression || + grandparentType === SyntaxType.VariableDeclarator)) + ? indent(parts) + : parts; +} + +export function embedTextBlock(path: NamedNodePath) { + const hasInterpolations = path.node.namedChildren.some( + ({ type }) => type === SyntaxType.StringInterpolation + ); + if (hasInterpolations || path.node.children[0].value === '"') { + return null; + } + + const language = findEmbeddedLanguage(path); + if (!language) { + return null; + } + + const text = unescapeTextBlockContents(textBlockContents(path.node)); + + return async ( + textToDoc: (text: string, options: Options) => Promise + ) => { + const doc = await textToDoc(text, { parser: language }); + return printTextBlock(path, escapeDocForTextBlock(doc)); + }; +} + +export function textBlockContents(node: NamedNode) { + const lines = node.value + .replace( + /(?<=^|[^\\])((?:\\\\)*)\\u+([0-9a-fA-F]{4})/g, + (_, backslashPairs: string, hex: string) => + backslashPairs + String.fromCharCode(parseInt(hex, 16)) + ) + .split("\n") + .slice(1); + const baseIndent = findBaseIndent(lines); + return lines + .map(line => line.slice(baseIndent)) + .join("\n") + .slice(0, -3); +} + +function findBaseIndent(lines: string[]) { + return Math.min( + ...lines.map(line => line.search(/\S/)).filter(indent => indent >= 0) + ); +} + +function findEmbeddedLanguage(path: NamedNodePath) { + return path.ancestors + .find( + ({ type, comments }) => + type === SyntaxType.Block || comments?.some(({ leading }) => leading) + ) + ?.comments?.filter(({ leading }) => leading) + .map( + ({ value }) => value.match(/^(?:\/\/|\/\*)\s*language\s*=\s*(\S+)/)?.[1] + ) + .findLast(language => language) + ?.toLowerCase(); +} + +function escapeDocForTextBlock(doc: Doc) { + return mapDoc(doc, currentDoc => + typeof currentDoc === "string" + ? currentDoc.replace(/\\|"""/g, match => `\\${match}`) + : currentDoc + ); +} + +function unescapeTextBlockContents(text: string) { + return text.replace( + /\\(?:([bstnfr"'\\])|\n|\r\n?|([0-3][0-7]{0,2}|[0-7]{1,2}))/g, + (_, single, octal) => { + if (single) { + switch (single) { + case "b": + return "\b"; + case "s": + return " "; + case "t": + return "\t"; + case "n": + return "\n"; + case "f": + return "\f"; + case "r": + return "\r"; + default: + return single; + } + } else if (octal) { + return String.fromCharCode(parseInt(octal, 8)); + } else { + return ""; + } + } + ); } export type NamedNodePrinters = { diff --git a/src/printers/lexical-structure.ts b/src/printers/lexical-structure.ts index dc68b966..13fd32f2 100644 --- a/src/printers/lexical-structure.ts +++ b/src/printers/lexical-structure.ts @@ -1,8 +1,9 @@ import { builders } from "prettier/doc"; -import { SyntaxType, type NamedNode } from "../node-types.ts"; +import { SyntaxType } from "../node-types.ts"; import { - findBaseIndent, + printTextBlock, printValue, + textBlockContents, type NamedNodePrinters } from "./helpers.ts"; @@ -17,25 +18,10 @@ export default { return path.map(print, "children"); } - const lines = path.node.children - .map(({ value }) => value) - .join("") - .split("\n") - .slice(1); - const baseIndent = findBaseIndent(lines); - const textBlock = join(hardline, [ - '"""', - ...lines.map(line => line.slice(baseIndent)) - ]); - const parentType = (path.parent as NamedNode | null)?.type; - const grandparentType = (path.grandparent as NamedNode | null)?.type; - return parentType === SyntaxType.AssignmentExpression || - parentType === SyntaxType.VariableDeclarator || - (path.node.fieldName === "object" && - (grandparentType === SyntaxType.AssignmentExpression || - grandparentType === SyntaxType.VariableDeclarator)) - ? indent(textBlock) - : textBlock; + return printTextBlock( + path, + join(hardline, textBlockContents(path.node).split("\n")) + ); }, string_fragment: printValue, diff --git a/test/unit-test/text-blocks/_input.java b/test/unit-test/text-blocks/_input.java index 0780d47e..0e85bed8 100644 --- a/test/unit-test/text-blocks/_input.java +++ b/test/unit-test/text-blocks/_input.java @@ -55,4 +55,49 @@ public void print(%s object) { ); } + void json() { + // language = json + String someJson = """ + {"glossary":{"title": "example glossary"}} + """; + + // language=json + String config = """ + { "name":"example", + "enabled" :true, + "timeout":30} + """; + + /* language = JSON */ + String query = """ + { + "sql":"SELECT * FROM users \ + WHERE active=1 \ + AND deleted=0", + "limit":10} + """; + } + + void java() { + // language=Java + String java = """ + class Class{void method() { + // comment + }} + """; + } + + void html() { + // language=html + String html = """ + Page Title

My First Heading

My first paragraph.

+ """; + } + + void unsupported() { + // language=unsupported + String unsupported = """ + function f(){let i=0;} + """; + } } diff --git a/test/unit-test/text-blocks/_output.java b/test/unit-test/text-blocks/_output.java index 566eeff1..1e487d39 100644 --- a/test/unit-test/text-blocks/_output.java +++ b/test/unit-test/text-blocks/_output.java @@ -52,4 +52,54 @@ public void print(%s object) { abc""" ); } + + void json() { + // language = json + String someJson = """ + { "glossary": { "title": "example glossary" } }"""; + + // language=json + String config = """ + { "name": "example", "enabled": true, "timeout": 30 }"""; + + /* language = JSON */ + String query = """ + { + "sql": "SELECT * FROM users WHERE active=1 AND deleted=0", + "limit": 10 + }"""; + } + + void java() { + // language=Java + String java = """ + class Class { + + void method() { + // comment + } + }"""; + } + + void html() { + // language=html + String html = """ + + + + Page Title + + +

My First Heading

+

My first paragraph.

+ + """; + } + + void unsupported() { + // language=unsupported + String unsupported = """ + function f(){let i=0;} + """; + } }